1
0
Fork 0
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:
Jrmi 2022-02-09 08:11:33 +00:00
parent 7af65b7d95
commit f443b45da8
16 changed files with 775 additions and 37 deletions

View file

@ -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]

View file

@ -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,

View file

@ -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"],
)

View file

@ -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/$",

View file

@ -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,)

View file

@ -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())

View file

@ -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

View file

@ -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."""

View file

@ -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.

View file

@ -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()

View file

@ -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),
)

View file

@ -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):

View file

@ -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

View file

@ -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(

View file

@ -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",
),
],
)

View file

@ -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())