Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/src/baserow/contrib/database/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ def make_adhoc_filter_api_params(combine_filters=True, view_is_aggregating=False
"descending (Z-A).",
)

ADHOC_GROUP_BY_API_PARAM = OpenApiParameter(
name="group_by",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Optionally the rows can be grouped by the provided field ids "
"separated by comma. By default a field is grouped in ascending (A-Z) "
"order, but by prepending the field with a '-' it can be grouped "
"descending (Z-A). Fields listed here use group-by ordering instead of "
"regular sort ordering.",
)

PAGINATION_API_PARAMS = (
OpenApiParameter(
name="limit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ class BaseExporterOptionsSerializer(serializers.Serializer):
"by comma. By default a field is ordered in ascending (A-Z) order, but by "
"prepending the field with a '-' it can be ordered descending (Z-A).",
)
group_by = serializers.CharField(
required=False,
allow_null=True,
allow_blank=True,
help_text="Optionally the rows can be grouped by provided field ids separated "
"by comma. Group-by fields are ordered before sort fields.",
)
fields = serializers.ListField(
required=False,
allow_null=True,
Expand Down
20 changes: 18 additions & 2 deletions backend/src/baserow/contrib/database/api/export/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from baserow.contrib.database.api.export.serializers import (
BaseExporterOptionsSerializer,
DisplayChoiceField,
ExportJobSerializer,
)
from baserow.contrib.database.api.fields.errors import (
Expand All @@ -32,6 +33,7 @@
ERROR_VIEW_DOES_NOT_EXIST,
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED,
ERROR_VIEW_NOT_IN_TABLE,
)
from baserow.contrib.database.export.exceptions import (
Expand All @@ -52,6 +54,7 @@
ViewDoesNotExist,
ViewFilterTypeDoesNotExist,
ViewFilterTypeNotAllowedForField,
ViewGroupByFieldNotSupported,
ViewNotInTable,
)
from baserow.contrib.database.views.handler import ViewHandler
Expand All @@ -73,14 +76,26 @@ def _validate_options(data: Dict[str, Any]) -> Dict[str, Any]:
options serializer based on the exporter_type and finally validates the data using
that serializer.

Uses ``return_validated=True`` so that omitted optional fields (e.g.
``group_by``) stay absent instead of appearing as ``None``. Because
``validated_data`` bypasses ``to_representation()``, we manually apply the
conversion for every ``DisplayChoiceField`` (delimiter, charset) so the
downstream code receives the actual Python values, not the display names.

:param data: A dict of data to serialize using an exporter options serializer.
:return: validated export options data
"""

option_serializers = table_exporter_registry.get_option_serializer_map()
validated_exporter_type = validate_data(BaseExporterOptionsSerializer, data)
serializer = option_serializers[validated_exporter_type["exporter_type"]]
return validate_data(serializer, data)
serializer_class = option_serializers[validated_exporter_type["exporter_type"]]
validated = validate_data(serializer_class, data, return_validated=True)

for field_name, field in serializer_class().fields.items():
if isinstance(field, DisplayChoiceField) and field_name in validated:
validated[field_name] = field.to_representation(validated[field_name])

return validated


class ExportTableView(APIView):
Expand Down Expand Up @@ -137,6 +152,7 @@ class ExportTableView(APIView):
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND,
OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
ViewGroupByFieldNotSupported: ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED,
}
)
def post(self, request, table_id):
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/contrib/database/api/rows/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ def get(self, request, table_id, query_params):
model = table.get_model()
queryset = model.objects.all().enhance_by_fields(**field_kwargs)
queryset = view_handler.apply_filters(view, queryset)
queryset = view_handler.apply_sorting(view, queryset)
queryset = view_handler.apply_ordering(view, queryset)
else:
model = table.get_model(
fields=fields,
Expand Down
46 changes: 23 additions & 23 deletions backend/src/baserow/contrib/database/api/views/grid/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"""

import json
import re
from collections import defaultdict
from typing import Any, Dict, Iterable, List, Optional, Tuple, Type

Expand All @@ -31,14 +30,13 @@
from baserow.config.settings.utils import str_to_bool, try_int
from baserow.contrib.database.api.views.utils import serialize_group_by_data_pages
from baserow.contrib.database.fields.exceptions import OrderByFieldNotFound
from baserow.contrib.database.fields.field_sortings import parse_order_string
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.utils import get_field_id_from_field_key
from baserow.contrib.database.views.constants import GROUP_BY_DATA_DEFAULT_LIMIT
from baserow.contrib.database.views.exceptions import ViewGroupByFieldNotSupported
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import DEFAULT_SORT_TYPE_KEY, ViewGroupBy
from baserow.core.utils import split_comma_separated_string
from baserow.contrib.database.views.models import ViewGroupBy

GROUP_BY_DATA_DESCENDANT_MAX_GROUPS = 2000
# Only a coarse backstop: a deep tree legitimately produces one parent page per internal
Expand Down Expand Up @@ -75,41 +73,43 @@ def parse_adhoc_view_group_bys(
or empty.
"""

if not raw_group_by:
if raw_group_by is None:
return None
if raw_group_by == "":
return []

if allowed_field_ids is not None:
allowed_field_ids = set(allowed_field_ids)

field_objects = model._field_objects
entries = parse_order_string(raw_group_by)

group_bys = []
try:
raw_entries = split_comma_separated_string(raw_group_by)
except ValueError:
raise OrderByFieldNotFound(raw_group_by)
for raw_entry in raw_entries:
field_id = get_field_id_from_field_key(raw_entry, strict=False)
for entry in entries:
if (
field_id is None
or field_id not in field_objects
or (allowed_field_ids is not None and field_id not in allowed_field_ids)
entry.field_key is None
or entry.field_key not in field_objects
or (
allowed_field_ids is not None
and entry.field_key not in allowed_field_ids
)
):
raise OrderByFieldNotFound(raw_entry)
raise OrderByFieldNotFound(entry.raw)

order = "DESC" if raw_entry.startswith("-") else "ASC"
type_match = re.search(r"\[(.*?)\]", raw_entry)
sort_type = type_match.group(1) if type_match else DEFAULT_SORT_TYPE_KEY

field_object = field_objects[field_id]
field_object = field_objects[entry.field_key]
if not field_object["type"].check_can_group_by(
field_object["field"], sort_type
field_object["field"], entry.sort_type
):
raise ViewGroupByFieldNotSupported(
f"It is not possible to group by field type "
f"{field_object['type'].type} using sort type {sort_type}."
f"{field_object['type'].type} using sort type {entry.sort_type}."
)

group_bys.append(ViewGroupBy(field_id=field_id, order=order, type=sort_type))
group_bys.append(
ViewGroupBy(
field_id=entry.field_key, order=entry.direction, type=entry.sort_type
)
)

return group_bys

Expand Down
72 changes: 61 additions & 11 deletions backend/src/baserow/contrib/database/api/views/grid/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ADHOC_FILTERS_API_PARAMS_NO_COMBINE,
ADHOC_FILTERS_API_PARAMS_WITH_AGGREGATION,
ADHOC_FILTERS_API_PARAMS_WITH_AGGREGATION_NO_COMBINE,
ADHOC_GROUP_BY_API_PARAM,
ADHOC_SORTING_API_PARAM,
EXCLUDE_COUNT_API_PARAM,
EXCLUDE_FIELDS_API_PARAM,
Expand Down Expand Up @@ -231,6 +232,7 @@ def get_permissions(self):
*PAGINATION_API_PARAMS,
*ADHOC_FILTERS_API_PARAMS_NO_COMBINE,
ADHOC_SORTING_API_PARAM,
ADHOC_GROUP_BY_API_PARAM,
INCLUDE_FIELDS_API_PARAM,
EXCLUDE_FIELDS_API_PARAM,
SEARCH_VALUE_API_PARAM,
Expand Down Expand Up @@ -278,6 +280,7 @@ def get_permissions(self):
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",
"ERROR_FILTERS_PARAM_VALIDATION_ERROR",
"ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED",
]
),
404: get_error_schema(
Expand All @@ -295,6 +298,7 @@ def get_permissions(self):
ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
ViewGroupByFieldNotSupported: ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED,
}
)
@allowed_includes("field_options", "row_metadata", "group_by_metadata")
Expand All @@ -321,6 +325,7 @@ def get(
exclude_fields = request.GET.get("exclude_fields")
adhoc_filters = AdHocFilters.from_request(request)
order_by = request.GET.get("order_by")
group_by = request.GET.get("group_by")

view_handler = ViewHandler()
view = view_handler.get_view_as_user(
Expand Down Expand Up @@ -353,6 +358,7 @@ def get(
order_by,
query_params,
hidden_field_ids=hidden_field_ids,
group_by=group_by,
)
model = queryset.model

Expand All @@ -363,15 +369,36 @@ def get(
queryset, request, field_ids, exclude_field_ids=hidden_field_ids
)

if group_by_metadata and view_type.can_group_by and view.viewgroupby_set.all():
group_by_fields = [
model._field_objects[group_by.field_id]["field"]
for group_by in view.viewgroupby_set.all()
]
serialized_group_by_metadata = serialize_group_by_fields_metadata(
queryset, group_by_fields, page
if group_by_metadata and view_type.can_group_by:
visible_field_ids = (
{fid for fid in model._field_objects if fid not in hidden_field_ids}
if hidden_field_ids
else None
)
response.data.update(group_by_metadata=serialized_group_by_metadata)
if group_by is not None:
adhoc_group_bys = parse_adhoc_view_group_bys(
group_by, model, allowed_field_ids=visible_field_ids
)
group_by_fields = (
[
model._field_objects[gb.field_id]["field"]
for gb in adhoc_group_bys
]
if adhoc_group_bys
else []
)
else:
group_by_fields = [
model._field_objects[gb.field_id]["field"]
for gb in view.viewgroupby_set.all()
if not hidden_field_ids or gb.field_id not in hidden_field_ids
]

if group_by_fields:
serialized_group_by_metadata = serialize_group_by_fields_metadata(
queryset, group_by_fields, page
)
response.data.update(group_by_metadata=serialized_group_by_metadata)

if field_options:
response.data.update(
Expand Down Expand Up @@ -561,21 +588,38 @@ def get(self, request, view_id, query_params):
serialize_group_by_data_pages([empty_group_by_data_page()], [])
)

hidden_field_ids = get_hidden_field_ids_for_view_user(request.user, view)
queryset = get_view_filtered_queryset(
request.user,
view,
adhoc_filters,
order_by=None,
query_params=query_params,
hidden_field_ids=hidden_field_ids,
)
# Users who can list but not update the view's group-bys (e.g. viewers)
# group ad hoc, so an explicit `group_by` parameter takes precedence over
# the saved configuration.
visible_field_ids = (
{
fid
for fid in queryset.model._field_objects
if fid not in hidden_field_ids
}
if hidden_field_ids
else None
)
view_group_bys = parse_adhoc_view_group_bys(
request.GET.get("group_by"), queryset.model
request.GET.get("group_by"),
queryset.model,
allowed_field_ids=visible_field_ids,
)
if view_group_bys is None:
view_group_bys = list(view.viewgroupby_set.all())
view_group_bys = [
gb
for gb in view.viewgroupby_set.all()
if not hidden_field_ids or gb.field_id not in hidden_field_ids
]

if not view_group_bys:
return Response(
Expand Down Expand Up @@ -1029,7 +1073,11 @@ def get(self, request, slug, query_params):
allowed_field_ids=visible_field_ids,
)
if view_group_bys is None:
view_group_bys = list(view.viewgroupby_set.all())
view_group_bys = [
gb
for gb in view.viewgroupby_set.all()
if gb.field_id in visible_field_ids
]

if not view_group_bys:
return Response(
Expand Down Expand Up @@ -1150,6 +1198,7 @@ class PublicGridViewRowsView(APIView):
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",
"ERROR_FILTERS_PARAM_VALIDATION_ERROR",
"ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED",
]
),
401: get_error_schema(["ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW"]),
Expand All @@ -1169,6 +1218,7 @@ class PublicGridViewRowsView(APIView):
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
NoAuthorizationToPubliclySharedView: ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW,
ViewGroupByFieldNotSupported: ERROR_VIEW_GROUP_BY_FIELD_NOT_SUPPORTED,
}
)
@allowed_includes("field_options", "group_by_metadata")
Expand Down
Loading
Loading