mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-14 17:18:33 +00:00
Footer calculations/aggregations in grid view v0.1 - Aggregation API only
This commit is contained in:
parent
7af65b7d95
commit
f443b45da8
16 changed files with 775 additions and 37 deletions
.editorconfig
backend
|
@ -11,7 +11,7 @@ insert_final_newline = true
|
|||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.{js,yml,scss,json,eslintrc,stylelintrc,vue,html}]
|
||||
[*.{js,yml,scss,eslintrc,stylelintrc,vue,html}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
|
|
|
@ -51,6 +51,11 @@ ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED = (
|
|||
HTTP_400_BAD_REQUEST,
|
||||
"The field does not support view sorting.",
|
||||
)
|
||||
ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST = (
|
||||
"ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The specified aggregation type does not exist.",
|
||||
)
|
||||
ERROR_UNRELATED_FIELD = (
|
||||
"ERROR_UNRELATED_FIELD",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
from drf_spectacular.plumbing import build_object_type
|
||||
|
||||
|
||||
field_aggregation_response_schema = build_object_type(
|
||||
{
|
||||
"value": {
|
||||
"type": "any",
|
||||
"description": "The aggregation result for the specified field.",
|
||||
"example": 5,
|
||||
},
|
||||
"total": {
|
||||
"type": "int",
|
||||
"description": (
|
||||
"The total value count. Only returned if `include=total` "
|
||||
"is specified as GET parameter."
|
||||
),
|
||||
"example": 7,
|
||||
},
|
||||
},
|
||||
required=["value"],
|
||||
)
|
|
@ -1,10 +1,20 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from .views import GridViewView, PublicGridViewInfoView, PublicGridViewRowsView
|
||||
from .views import (
|
||||
GridViewView,
|
||||
PublicGridViewInfoView,
|
||||
PublicGridViewRowsView,
|
||||
GridViewFieldAggregationView,
|
||||
)
|
||||
|
||||
app_name = "baserow.contrib.database.api.views.grid"
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"(?P<view_id>[0-9]+)/aggregation/(?P<field_id>[0-9]+)/$",
|
||||
GridViewFieldAggregationView.as_view(),
|
||||
name="field-aggregation",
|
||||
),
|
||||
re_path(r"(?P<view_id>[0-9]+)/$", GridViewView.as_view(), name="list"),
|
||||
re_path(
|
||||
r"(?P<slug>[-\w]+)/public/info/$",
|
||||
|
|
|
@ -30,11 +30,14 @@ from baserow.contrib.database.api.views.serializers import (
|
|||
from baserow.contrib.database.api.views.errors import (
|
||||
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
|
||||
ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
|
||||
ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST,
|
||||
)
|
||||
from baserow.contrib.database.api.fields.errors import (
|
||||
ERROR_FIELD_DOES_NOT_EXIST,
|
||||
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
|
||||
ERROR_ORDER_BY_FIELD_NOT_FOUND,
|
||||
ERROR_FILTER_FIELD_NOT_FOUND,
|
||||
ERROR_FIELD_NOT_IN_TABLE,
|
||||
)
|
||||
from baserow.contrib.database.rows.registries import row_metadata_registry
|
||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||
|
@ -43,23 +46,33 @@ from baserow.contrib.database.views.models import GridView
|
|||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
view_aggregation_type_registry,
|
||||
)
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewFilterTypeNotAllowedForField,
|
||||
ViewFilterTypeDoesNotExist,
|
||||
AggregationTypeDoesNotExist,
|
||||
)
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.fields.field_filters import (
|
||||
FILTER_TYPE_AND,
|
||||
FILTER_TYPE_OR,
|
||||
)
|
||||
from baserow.contrib.database.fields.exceptions import (
|
||||
FieldDoesNotExist,
|
||||
OrderByFieldNotFound,
|
||||
OrderByFieldNotPossible,
|
||||
FilterFieldNotFound,
|
||||
FieldNotInTable,
|
||||
)
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from .errors import ERROR_GRID_DOES_NOT_EXIST
|
||||
from .serializers import GridViewFilterSerializer
|
||||
from .schemas import field_aggregation_response_schema
|
||||
|
||||
|
||||
def get_available_aggregation_type():
|
||||
return [f.type for f in view_aggregation_type_registry.get_all()]
|
||||
|
||||
|
||||
class GridViewView(APIView):
|
||||
|
@ -182,7 +195,7 @@ class GridViewView(APIView):
|
|||
If the limit get parameter is provided the limit/offset pagination will be used
|
||||
else the page number pagination.
|
||||
|
||||
Optionally the field options can also be included in the response if the the
|
||||
Optionally the field options can also be included in the response if the
|
||||
`field_options` are provided in the include GET parameter.
|
||||
"""
|
||||
|
||||
|
@ -291,6 +304,127 @@ class GridViewView(APIView):
|
|||
return Response(serializer.data)
|
||||
|
||||
|
||||
class GridViewFieldAggregationView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny()]
|
||||
|
||||
return super().get_permissions()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="view_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Select the view you want the aggregation for.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="field_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The field id you want to aggregate",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="type",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description=(
|
||||
"The aggregation type you want. Available aggregation types: "
|
||||
)
|
||||
+ ", ".join(get_available_aggregation_type()),
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="include",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description=(
|
||||
"if `include` is set to `total`, the total row count will be "
|
||||
"returned with the result."
|
||||
),
|
||||
),
|
||||
],
|
||||
tags=["Database table grid view"],
|
||||
operation_id="get_database_table_grid_view_field_aggregation",
|
||||
description=(
|
||||
"Computes an aggregation of all values for a specific field from the selected "
|
||||
"grid view. You can select the aggregation type by specifying "
|
||||
"the `type` GET parameter. If filters are configured for the selected "
|
||||
"view, the aggregation is calculated only on filtered rows. "
|
||||
"The total count of rows is also always returned with the result."
|
||||
"You need to have read permissions on the view to request aggregations."
|
||||
),
|
||||
responses={
|
||||
200: field_aggregation_response_schema,
|
||||
400: get_error_schema(
|
||||
[
|
||||
"ERROR_USER_NOT_IN_GROUP",
|
||||
"ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST",
|
||||
"ERROR_FIELD_NOT_IN_TABLE",
|
||||
]
|
||||
),
|
||||
404: get_error_schema(
|
||||
[
|
||||
"ERROR_FIELD_DOES_NOT_EXIST",
|
||||
"ERROR_GRID_DOES_NOT_EXIST",
|
||||
]
|
||||
),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
|
||||
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
|
||||
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
|
||||
AggregationTypeDoesNotExist: ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
@allowed_includes("total")
|
||||
def get(self, request, view_id, field_id, total):
|
||||
"""
|
||||
Returns the aggregation value for the specified view/field considering
|
||||
the filters configured for this grid view.
|
||||
Also return the total count to be able to make percentage on client side.
|
||||
"""
|
||||
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_view(view_id, GridView)
|
||||
|
||||
# Check permission
|
||||
view.table.database.group.has_user(
|
||||
request.user, raise_error=True, allow_if_template=True
|
||||
)
|
||||
field_instance = FieldHandler().get_field(field_id)
|
||||
|
||||
# Check whether the field belongs to the table
|
||||
if view.table_id != field_instance.table_id:
|
||||
raise FieldNotInTable(
|
||||
f"The field {field_instance.pk} does not belong to table "
|
||||
f"{view.table_id}."
|
||||
)
|
||||
|
||||
model = view.table.get_model(field_ids=[], fields=[field_instance])
|
||||
|
||||
aggregation_type = request.GET.get("type")
|
||||
|
||||
# Compute aggregation
|
||||
aggregations = view_handler.get_field_aggregations(
|
||||
view, [(field_instance, aggregation_type)], model, with_total=total
|
||||
)
|
||||
|
||||
result = {
|
||||
"value": aggregations[f"field_{field_instance.id}__{aggregation_type}"],
|
||||
}
|
||||
|
||||
if total:
|
||||
result["total"] = aggregations["total"]
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
class PublicGridViewRowsView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
|
|
|
@ -53,7 +53,11 @@ class DatabaseConfig(AppConfig):
|
|||
def ready(self):
|
||||
self.prevent_generated_model_for_registering()
|
||||
|
||||
from .views.registries import view_type_registry, view_filter_type_registry
|
||||
from .views.registries import (
|
||||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
view_aggregation_type_registry,
|
||||
)
|
||||
from .fields.registries import field_type_registry, field_converter_registry
|
||||
from .export.registries import table_exporter_registry
|
||||
from .formula.registries import (
|
||||
|
@ -187,6 +191,14 @@ class DatabaseConfig(AppConfig):
|
|||
view_filter_type_registry.register(MultipleSelectHasViewFilterType())
|
||||
view_filter_type_registry.register(MultipleSelectHasNotViewFilterType())
|
||||
|
||||
from .views.view_aggregations import (
|
||||
EmptyCountViewAggregationType,
|
||||
NotEmptyCountViewAggregationType,
|
||||
)
|
||||
|
||||
view_aggregation_type_registry.register(EmptyCountViewAggregationType())
|
||||
view_aggregation_type_registry.register(NotEmptyCountViewAggregationType())
|
||||
|
||||
from .application_types import DatabaseApplicationType
|
||||
|
||||
application_type_registry.register(DatabaseApplicationType())
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
from typing import Any, List
|
||||
|
||||
from django.db.models import Q
|
||||
from django.contrib.postgres.fields import JSONField, ArrayField
|
||||
from django.db import models as django_models
|
||||
from django.db.models import (
|
||||
Q,
|
||||
BooleanField,
|
||||
DurationField,
|
||||
)
|
||||
from django.db.models.fields.related import ManyToManyField, ForeignKey
|
||||
|
||||
from baserow.core.registry import (
|
||||
Instance,
|
||||
|
@ -112,6 +119,50 @@ class FieldType(
|
|||
|
||||
return queryset
|
||||
|
||||
def empty_query(
|
||||
self,
|
||||
field_name: str,
|
||||
model_field: django_models.Field,
|
||||
field: Field,
|
||||
):
|
||||
"""
|
||||
Returns a Q filter which performs an empty filter over the
|
||||
provided field for this specific type of field.
|
||||
|
||||
:param field_name: The name of the field.
|
||||
:type field_name: str
|
||||
:param model_field: The field's actual django field model instance.
|
||||
:type model_field: django_models.Field
|
||||
:param field: The related field's instance.
|
||||
:type field: Field
|
||||
:return: A Q filter.
|
||||
:rtype: Q
|
||||
"""
|
||||
|
||||
fs = [ManyToManyField, ForeignKey, DurationField, ArrayField]
|
||||
# If the model_field is a ManyToMany field we only have to check if it is None.
|
||||
if any(isinstance(model_field, f) for f in fs):
|
||||
return Q(**{f"{field_name}": None})
|
||||
|
||||
if isinstance(model_field, BooleanField):
|
||||
return Q(**{f"{field_name}": False})
|
||||
|
||||
q = Q(**{f"{field_name}__isnull": True})
|
||||
q = q | Q(**{f"{field_name}": None})
|
||||
|
||||
if isinstance(model_field, JSONField):
|
||||
q = q | Q(**{f"{field_name}": []}) | Q(**{f"{field_name}": {}})
|
||||
|
||||
# If the model field accepts an empty string as value we are going to add
|
||||
# that to the or statement.
|
||||
try:
|
||||
model_field.get_prep_value("")
|
||||
q = q | Q(**{f"{field_name}": ""})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return q
|
||||
|
||||
def contains_query(self, field_name, value, model_field, field):
|
||||
"""
|
||||
Returns a Q or AnnotatedQ filter which performs a contains filter over the
|
||||
|
|
|
@ -47,6 +47,18 @@ class ViewFilterNotSupported(Exception):
|
|||
"""Raised when the view type does not support filters."""
|
||||
|
||||
|
||||
class FieldAggregationNotSupported(Exception):
|
||||
"""Raised when the view type does not support field aggregation."""
|
||||
|
||||
|
||||
class AggregationTypeDoesNotExist(Exception):
|
||||
"""Raised when trying to get an aggregation type that does not exist."""
|
||||
|
||||
|
||||
class AggregationTypeAlreadyRegistered(Exception):
|
||||
"""Raised when trying to register an aggregation type that exists already."""
|
||||
|
||||
|
||||
class ViewFilterTypeNotAllowedForField(Exception):
|
||||
"""Raised when the view filter type is compatible with the field type."""
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from typing import Dict, Any, List, Optional, Iterable
|
||||
from typing import Dict, Any, List, Optional, Iterable, Tuple
|
||||
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.db.models import F
|
||||
from django.db import models as django_models
|
||||
from django.db.models import F, Count
|
||||
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
from baserow.contrib.database.fields.field_filters import FilterBuilder
|
||||
|
@ -30,10 +31,15 @@ from .exceptions import (
|
|||
ViewSortFieldAlreadyExist,
|
||||
ViewSortFieldNotSupported,
|
||||
ViewDoesNotSupportFieldOptions,
|
||||
FieldAggregationNotSupported,
|
||||
CannotShareViewTypeError,
|
||||
)
|
||||
from .models import View, ViewFilter, ViewSort
|
||||
from .registries import view_type_registry, view_filter_type_registry
|
||||
from .registries import (
|
||||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
view_aggregation_type_registry,
|
||||
)
|
||||
from .signals import (
|
||||
view_created,
|
||||
view_updated,
|
||||
|
@ -818,6 +824,79 @@ class ViewHandler:
|
|||
queryset = queryset.search_all_fields(search, only_search_by_field_ids)
|
||||
return queryset
|
||||
|
||||
def get_field_aggregations(
|
||||
self,
|
||||
view: View,
|
||||
aggregations: Iterable[Tuple[django_models.Field, str]],
|
||||
model: Table = None,
|
||||
with_total: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns a dict of aggregation for given (field, aggregation_type) couple.
|
||||
Each dict key is the name of the field suffixed with two `_` then the
|
||||
aggregation type. ex: "field_42__empty_count" for the empty count
|
||||
aggregation of field with id 42.
|
||||
|
||||
:param view: The view to get the field aggregation for.
|
||||
:param aggregations: A list of (field_instance, aggregation_type).
|
||||
:param model: The model for this view table to generate the aggregation
|
||||
query from, if not specified then the model will be generated
|
||||
automatically.
|
||||
:param with_total: Whether the total row count should be returned in the
|
||||
result.
|
||||
:raises FieldAggregationNotSupported: When the view type doesn't support
|
||||
field aggregation.
|
||||
:raises FieldNotInTable: When the field does not belong to the specified
|
||||
view.
|
||||
:returns: A dict of aggregation value
|
||||
"""
|
||||
|
||||
if model is None:
|
||||
model = view.table.get_model()
|
||||
|
||||
queryset = model.objects.all().enhance_by_fields()
|
||||
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
|
||||
# Check if view supports field aggregation
|
||||
if not view_type.can_aggregate_field:
|
||||
raise FieldAggregationNotSupported(
|
||||
f"Field aggregation is not supported for {view_type.type} views."
|
||||
)
|
||||
|
||||
# Apply filters to have accurate aggregation
|
||||
if view_type.can_filter:
|
||||
queryset = self.apply_filters(view, queryset)
|
||||
|
||||
aggregation_dict = {}
|
||||
for (field_instance, aggregation_type_name) in aggregations:
|
||||
|
||||
# Check whether the field belongs to the table.
|
||||
if field_instance.table_id != view.table_id:
|
||||
raise FieldNotInTable(
|
||||
f"The field {field_instance.pk} does not belong to table "
|
||||
f"{view.table.id}."
|
||||
)
|
||||
|
||||
# Prepare data for .get_aggregation call
|
||||
field = model._field_objects[field_instance.id]["field"]
|
||||
field_name = field_instance.db_column
|
||||
model_field = model._meta.get_field(field_name)
|
||||
aggregation_type = view_aggregation_type_registry.get(aggregation_type_name)
|
||||
|
||||
# Add the aggregation for the field
|
||||
aggregation_dict[
|
||||
f"{field_name}__{aggregation_type_name}"
|
||||
] = aggregation_type.get_aggregation(field_name, model_field, field)
|
||||
|
||||
if with_total:
|
||||
# Add total to allow further calculation on the client
|
||||
aggregation_dict["total"] = Count("id")
|
||||
|
||||
queryset = queryset.aggregate(**aggregation_dict)
|
||||
|
||||
return queryset
|
||||
|
||||
def rotate_view_slug(self, user, view):
|
||||
"""
|
||||
Rotates the slug of the provided view.
|
||||
|
|
|
@ -18,13 +18,16 @@ from baserow.core.registry import (
|
|||
ImportExportMixin,
|
||||
MapAPIExceptionsInstanceMixin,
|
||||
)
|
||||
from baserow.contrib.database import models
|
||||
from baserow.contrib.database.fields import models as field_models
|
||||
from django.db import models as django_models
|
||||
|
||||
from .exceptions import (
|
||||
ViewTypeAlreadyRegistered,
|
||||
ViewTypeDoesNotExist,
|
||||
ViewFilterTypeAlreadyRegistered,
|
||||
ViewFilterTypeDoesNotExist,
|
||||
AggregationTypeDoesNotExist,
|
||||
AggregationTypeAlreadyRegistered,
|
||||
)
|
||||
|
||||
|
||||
|
@ -82,6 +85,12 @@ class ViewType(
|
|||
sort to the view.
|
||||
"""
|
||||
|
||||
can_aggregate_field = False
|
||||
"""
|
||||
Indicates if the view supports field aggregation. If not, it will not be possible
|
||||
to compute fields aggregation for this view type.
|
||||
"""
|
||||
|
||||
can_share = False
|
||||
"""
|
||||
Indicates if the view supports being shared via a public link.
|
||||
|
@ -431,7 +440,9 @@ class ViewFilterType(Instance):
|
|||
view_filter_type_registry.register(ExampleViewFilterType())
|
||||
"""
|
||||
|
||||
compatible_field_types: List[Union[str, Callable[["models.Field"], bool]]] = []
|
||||
compatible_field_types: List[
|
||||
Union[str, Callable[["field_models.Field"], bool]]
|
||||
] = []
|
||||
"""
|
||||
Defines which field types are compatible with the filter. Only the supported ones
|
||||
can be used in combination with the field. The values in this list can either be
|
||||
|
@ -538,7 +549,49 @@ class ViewFilterTypeRegistry(Registry):
|
|||
already_registered_exception_class = ViewFilterTypeAlreadyRegistered
|
||||
|
||||
|
||||
class ViewAggregationType(Instance):
|
||||
"""
|
||||
If you want to aggregate the values of fields in a view, you can use a field aggregation.
|
||||
For example you can compute a sum of all values of a field in a table.
|
||||
"""
|
||||
|
||||
def get_aggregation(
|
||||
self,
|
||||
field_name: str,
|
||||
model_field: django_models.Field,
|
||||
field: field_models.Field,
|
||||
) -> django_models.Aggregate:
|
||||
"""
|
||||
Should return the requested django aggregation object based on
|
||||
the provided arguments.
|
||||
|
||||
:param field_name: The name of the field that needs to be aggregated.
|
||||
:type field_name: str
|
||||
:param model_field: The field extracted from the model.
|
||||
:type model_field: django_models.Field
|
||||
:param field: The instance of the underlying baserow field.
|
||||
:type field: Field
|
||||
:return: A django aggregation object for this specific field.
|
||||
"""
|
||||
|
||||
raise NotImplementedError(
|
||||
"Each aggregation type must have his own get_aggregation method."
|
||||
)
|
||||
|
||||
|
||||
class ViewAggregationTypeRegistry(Registry):
|
||||
"""
|
||||
This registry contains all the available field aggregation operators. A field
|
||||
aggregation allow to summarize all the values for a specific field of a table.
|
||||
"""
|
||||
|
||||
name = "field_aggregation"
|
||||
does_not_exist_exception_class = AggregationTypeDoesNotExist
|
||||
already_registered_exception_class = AggregationTypeAlreadyRegistered
|
||||
|
||||
|
||||
# A default view type registry is created here, this is the one that is used
|
||||
# throughout the whole Baserow application to add a new view type.
|
||||
view_type_registry = ViewTypeRegistry()
|
||||
view_filter_type_registry = ViewFilterTypeRegistry()
|
||||
view_aggregation_type_registry = ViewAggregationTypeRegistry()
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
from .registries import ViewAggregationType
|
||||
from django.db.models import Count
|
||||
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
|
||||
# See official django documentation for list of aggregator:
|
||||
# https://docs.djangoproject.com/en/4.0/ref/models/querysets/#aggregation-functions
|
||||
|
||||
|
||||
class EmptyCountViewAggregationType(ViewAggregationType):
|
||||
"""
|
||||
The empty count aggregation counts how many values are considered empty for
|
||||
the given field.
|
||||
"""
|
||||
|
||||
type = "empty_count"
|
||||
|
||||
def get_aggregation(self, field_name, model_field, field):
|
||||
field_type = field_type_registry.get_by_model(field)
|
||||
return Count(
|
||||
"id",
|
||||
filter=field_type.empty_query(field_name, model_field, field),
|
||||
)
|
||||
|
||||
|
||||
class NotEmptyCountViewAggregationType(ViewAggregationType):
|
||||
"""
|
||||
The empty count aggregation counts how many values aren't considered empty for
|
||||
the given field.
|
||||
"""
|
||||
|
||||
type = "not_empty_count"
|
||||
|
||||
def get_aggregation(self, field_name, model_field, field):
|
||||
field_type = field_type_registry.get_by_model(field)
|
||||
|
||||
return Count(
|
||||
"id",
|
||||
filter=~field_type.empty_query(field_name, model_field, field),
|
||||
)
|
|
@ -4,11 +4,9 @@ from math import floor, ceil
|
|||
|
||||
from dateutil import parser
|
||||
from dateutil.parser import ParserError
|
||||
from django.contrib.postgres.fields import JSONField, ArrayField
|
||||
from django.contrib.postgres.aggregates.general import ArrayAgg
|
||||
from django.db.models import Q, IntegerField, BooleanField, DateTimeField, DurationField
|
||||
from django.db.models import Q, IntegerField, DateTimeField
|
||||
from django.db.models.functions import Cast, Length
|
||||
from django.db.models.fields.related import ManyToManyField, ForeignKey
|
||||
from pytz import timezone, all_timezones
|
||||
|
||||
from baserow.contrib.database.fields.field_filters import AnnotatedQ
|
||||
|
@ -753,30 +751,9 @@ class EmptyViewFilterType(ViewFilterType):
|
|||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field, field):
|
||||
fs = [ManyToManyField, ForeignKey, DurationField, ArrayField]
|
||||
# If the model_field is a ManyToMany field we only have to check if it is None.
|
||||
if any(isinstance(model_field, f) for f in fs):
|
||||
return Q(**{f"{field_name}": None})
|
||||
field_type = field_type_registry.get_by_model(field)
|
||||
|
||||
if isinstance(model_field, BooleanField):
|
||||
return Q(**{f"{field_name}": False})
|
||||
|
||||
q = Q(**{f"{field_name}__isnull": True})
|
||||
q.add(Q(**{f"{field_name}": None}), Q.OR)
|
||||
|
||||
if isinstance(model_field, JSONField):
|
||||
q.add(Q(**{f"{field_name}": []}), Q.OR)
|
||||
q.add(Q(**{f"{field_name}": {}}), Q.OR)
|
||||
|
||||
# If the model field accepts an empty string as value we are going to add
|
||||
# that to the or statement.
|
||||
try:
|
||||
model_field.get_prep_value("")
|
||||
q.add(Q(**{f"{field_name}": ""}), Q.OR)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return q
|
||||
return field_type.empty_query(field_name, model_field, field)
|
||||
|
||||
|
||||
class NotEmptyViewFilterType(NotViewFilterTypeMixin, EmptyViewFilterType):
|
||||
|
|
|
@ -37,6 +37,7 @@ class GridViewType(ViewType):
|
|||
model_class = GridView
|
||||
field_options_model_class = GridViewFieldOptions
|
||||
field_options_serializer_class = GridViewFieldOptionsSerializer
|
||||
can_aggregate_field = True
|
||||
can_share = True
|
||||
when_shared_publicly_requires_realtime_events = True
|
||||
|
||||
|
|
|
@ -406,6 +406,174 @@ def test_list_filtered_rows(api_client, data_fixture):
|
|||
assert f"field_{boolean_field.id}" in response_json[0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_field_aggregation(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token(
|
||||
email="test@test.nl", password="password", first_name="Test1"
|
||||
)
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
text_field = data_fixture.create_text_field(
|
||||
table=table, order=0, name="Color", text_default="white"
|
||||
)
|
||||
number_field = data_fixture.create_number_field(
|
||||
table=table, order=1, name="Horsepower"
|
||||
)
|
||||
boolean_field = data_fixture.create_boolean_field(
|
||||
table=table, order=2, name="For sale"
|
||||
)
|
||||
grid = data_fixture.create_grid_view(table=table)
|
||||
grid_2 = data_fixture.create_grid_view()
|
||||
|
||||
table2 = data_fixture.create_database_table(user=user)
|
||||
text_field2 = data_fixture.create_text_field(
|
||||
table=table2, order=0, name="Color", text_default="white"
|
||||
)
|
||||
|
||||
# Test missing grid view
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": 9999, "field_id": text_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_GRID_DOES_NOT_EXIST"
|
||||
|
||||
# Test aggregation on missing field
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid.id, "field_id": 9999},
|
||||
)
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FIELD_DOES_NOT_EXIST"
|
||||
|
||||
# Test user not authorized
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid_2.id, "field_id": text_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
# Test field not in table
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid.id, "field_id": text_field2.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_FIELD_NOT_IN_TABLE"
|
||||
|
||||
# Test missing auth token
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid.id, "field_id": text_field.id},
|
||||
)
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid.id, "field_id": text_field.id},
|
||||
)
|
||||
|
||||
# Test bad aggregation type
|
||||
response = api_client.get(
|
||||
url + f"?type=bad_aggregation_type",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_AGGREGATION_TYPE_DOES_NOT_EXIST"
|
||||
|
||||
# Test normal response with no data
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
|
||||
assert response_json == {f"value": 0}
|
||||
|
||||
# Add more data
|
||||
model = grid.table.get_model()
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": "Green",
|
||||
f"field_{number_field.id}": 10,
|
||||
f"field_{boolean_field.id}": False,
|
||||
}
|
||||
)
|
||||
model.objects.create()
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": "",
|
||||
f"field_{number_field.id}": 0,
|
||||
f"field_{boolean_field.id}": False,
|
||||
}
|
||||
)
|
||||
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": None,
|
||||
f"field_{number_field.id}": 1200,
|
||||
f"field_{boolean_field.id}": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Count empty "Color" field
|
||||
response = api_client.get(
|
||||
url + f"?type=empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
assert response_json == {f"value": 2}
|
||||
|
||||
url = reverse(
|
||||
"api:database:views:grid:field-aggregation",
|
||||
kwargs={"view_id": grid.id, "field_id": boolean_field.id},
|
||||
)
|
||||
|
||||
# Count not empty "For sale" field
|
||||
response = api_client.get(
|
||||
url + f"?type=not_empty_count",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
assert response_json == {
|
||||
"value": 1,
|
||||
}
|
||||
|
||||
# Count with total
|
||||
response = api_client.get(
|
||||
url + f"?type=not_empty_count&include=total",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
assert response_json == {"value": 1, "total": 4}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_grid_view_field_options(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token(
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
import pytest
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
from baserow.contrib.database.views.exceptions import FieldAggregationNotSupported
|
||||
from baserow.test_utils.helpers import setup_interesting_test_table
|
||||
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_empty_count_aggregation(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
boolean_field = data_fixture.create_boolean_field(table=table)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
|
||||
view_handler = ViewHandler()
|
||||
|
||||
model = table.get_model()
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": "Aaa",
|
||||
f"field_{number_field.id}": 30,
|
||||
f"field_{boolean_field.id}": True,
|
||||
}
|
||||
)
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": "Aaa",
|
||||
f"field_{number_field.id}": 20,
|
||||
f"field_{boolean_field.id}": False,
|
||||
}
|
||||
)
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": "",
|
||||
f"field_{number_field.id}": 0,
|
||||
f"field_{boolean_field.id}": True,
|
||||
}
|
||||
)
|
||||
model.objects.create(
|
||||
**{
|
||||
f"field_{text_field.id}": None,
|
||||
f"field_{number_field.id}": None,
|
||||
f"field_{boolean_field.id}": False,
|
||||
}
|
||||
)
|
||||
|
||||
result = view_handler.get_field_aggregations(
|
||||
grid_view,
|
||||
[
|
||||
(
|
||||
text_field,
|
||||
"empty_count",
|
||||
),
|
||||
(
|
||||
text_field,
|
||||
"not_empty_count",
|
||||
),
|
||||
(
|
||||
boolean_field,
|
||||
"empty_count",
|
||||
),
|
||||
(
|
||||
boolean_field,
|
||||
"not_empty_count",
|
||||
),
|
||||
(
|
||||
number_field,
|
||||
"empty_count",
|
||||
),
|
||||
(
|
||||
number_field,
|
||||
"not_empty_count",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert result[f"field_{text_field.id}__empty_count"] == 2
|
||||
assert result[f"field_{text_field.id}__not_empty_count"] == 2
|
||||
|
||||
assert result[f"field_{boolean_field.id}__empty_count"] == 2
|
||||
assert result[f"field_{boolean_field.id}__not_empty_count"] == 2
|
||||
|
||||
assert result[f"field_{number_field.id}__empty_count"] == 1
|
||||
assert result[f"field_{number_field.id}__not_empty_count"] == 3
|
||||
|
||||
result = view_handler.get_field_aggregations(
|
||||
grid_view,
|
||||
[
|
||||
(
|
||||
text_field,
|
||||
"empty_count",
|
||||
),
|
||||
],
|
||||
with_total=True,
|
||||
)
|
||||
|
||||
assert result[f"total"] == 4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_empty_count_aggregation_for_interesting_table(data_fixture):
|
||||
table, _, _, _ = setup_interesting_test_table(data_fixture)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
view_handler = ViewHandler()
|
||||
|
||||
aggregation_query = []
|
||||
for field in model._field_objects.values():
|
||||
|
||||
aggregation_query.append(
|
||||
(
|
||||
field["field"],
|
||||
"empty_count",
|
||||
)
|
||||
)
|
||||
|
||||
aggregation_query.append(
|
||||
(
|
||||
field["field"],
|
||||
"not_empty_count",
|
||||
)
|
||||
)
|
||||
|
||||
result = view_handler.get_field_aggregations(
|
||||
grid_view, aggregation_query, model=model, with_total=True
|
||||
)
|
||||
|
||||
for field in model._field_objects.values():
|
||||
field_id = field["field"].id
|
||||
assert (
|
||||
result[f"field_{field_id}__empty_count"]
|
||||
+ result[f"field_{field_id}__not_empty_count"]
|
||||
== result["total"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_aggregation_errors(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
form_view = data_fixture.create_form_view(table=table)
|
||||
|
||||
table2 = data_fixture.create_database_table(user=user)
|
||||
data_fixture.create_text_field(table=table2)
|
||||
grid_view = data_fixture.create_grid_view(table=table2)
|
||||
|
||||
view_handler = ViewHandler()
|
||||
|
||||
with pytest.raises(FieldAggregationNotSupported):
|
||||
view_handler.get_field_aggregations(
|
||||
form_view,
|
||||
[
|
||||
(
|
||||
text_field,
|
||||
"empty_count",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(FieldNotInTable):
|
||||
view_handler.get_field_aggregations(
|
||||
grid_view,
|
||||
[
|
||||
(
|
||||
text_field,
|
||||
"empty_count",
|
||||
),
|
||||
],
|
||||
)
|
|
@ -644,7 +644,7 @@ def test_apply_filters(data_fixture):
|
|||
view=grid_view, field=text_field, type="equal", value="Value 1"
|
||||
)
|
||||
|
||||
# Should raise a value error if the modal doesn't have the _field_objects property.
|
||||
# Should raise a value error if the model doesn't have the _field_objects property.
|
||||
with pytest.raises(ValueError):
|
||||
view_handler.apply_filters(grid_view, GridView.objects.all())
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue