From 012d1f19089dff4bb0125d982cc6688374e4f68a Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 15 Oct 2019 11:30:21 +0200 Subject: [PATCH] feat(filters): implement new order filter This introduces an ordering feature analog to the rewritten filtering. What this allows us to do is to order by a chain of arbitrary fields, choosing ascending/descending on the way. New fields can be added easily by extending an `OrderSet` class - similar to `FilterSet` classes we already have. --- caluma/core/filters.py | 144 ++++++++++++++++++++++++++++++---------- caluma/core/ordering.py | 101 ++++++++++++++++++++++++++++ caluma/form/ordering.py | 65 ++++++++++++++++++ 3 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 caluma/core/ordering.py create mode 100644 caluma/form/ordering.py diff --git a/caluma/core/filters.py b/caluma/core/filters.py index 878ae92d2..c95d193ab 100644 --- a/caluma/core/filters.py +++ b/caluma/core/filters.py @@ -37,6 +37,7 @@ GlobalIDMultipleChoiceField, SlugMultipleChoiceField, ) +from .ordering import CalumaOrdering from .relay import extract_global_id from .types import DjangoConnectionField @@ -64,7 +65,73 @@ def clean(self, data): return data -def FilterCollectionFactory(filterset_class): +class AscDesc(Enum): + ASC = "ASC" + DESC = "DESC" + + +class FilterCollectionFilter(Filter): + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + filter_coll = self.filterset_class() + for flt in value: + invert = flt.pop("invert", False) + flt_key = list(flt.keys())[0] + flt_val = flt[flt_key] + filter = filter_coll.get_filters()[flt_key] + + new_qs = filter.filter(qs, flt_val) + + if invert: + qs = qs.exclude(pk__in=new_qs) + else: + qs = new_qs + + return qs + + +class FilterCollectionOrdering(Filter): + def _order_part(self, qs, ord, filter_coll): + direction = ord.pop("direction", "ASC") + + assert len(ord) == 1 + filt_name = list(ord.keys())[0] + filter = filter_coll.get_filters()[filt_name] + qs, field = filter.get_ordering_value(qs, ord[filt_name]) + + # Normally, people use ascending order, and in this context it seems + # natural to have NULL entries at the end. + # Making the `nulls_first`/`nulls_last` parameter accessible in the + # GraphQL interface would be overkill, at least for now. + return ( + qs, + OrderBy( + field, + descending=(direction == "DESC"), + nulls_first=(direction == "DESC"), + nulls_last=(direction == "ASC"), + ), + ) + + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + filter_coll = self.filterset_class() + + order_by = [] + for ord in value: + qs, order_field = self._order_part(qs, ord, filter_coll) + order_by.append(order_field) + + if order_by: + qs = qs.order_by(*order_by) + + return qs + + +def FilterCollectionFactory(filterset_class, ordering): # noqa:C901 """ Build a single filter from a `FilterSet`. @@ -78,39 +145,17 @@ def FilterCollectionFactory(filterset_class): Usage: >>> class MyFilterSet(FilterSet): - ... filter = FilterCollectionFactory(FilterSetWithActualFilters) + ... filter = FilterCollectionFactory(FilterSetWithActualFilters, ordering=False) """ field_class_name = f"{filterset_class.__name__}Field" field_type_name = f"{filterset_class.__name__}Type" + collection_name = f"{filterset_class.__name__}Collection" # The field class. custom_field_class = type(field_class_name, (CompositeFieldClass,), {}) - class FilterCollection(Filter): - field_class = custom_field_class - - def filter(self, qs, value): - if value in EMPTY_VALUES: - return qs - - filter_coll = filterset_class() - for flt in value: - invert = flt.pop("invert", False) - flt_key = list(flt.keys())[0] - flt_val = flt[flt_key] - filter = filter_coll.get_filters()[flt_key] - - new_qs = filter.filter(qs, flt_val) - - if invert: - qs = qs.exclude(pk__in=new_qs) - else: - qs = new_qs - - return qs - @convert_form_field.register(custom_field_class) def convert_field(field): @@ -124,13 +169,23 @@ def convert_field(field): def _get_or_make_field(name, filt): return convert_form_field(filt.field) + def _should_include_filter(filt): + # if we're in ordering mode, we want + # to return True for all CalumaOrdering types, + # and if it's false, we want the opposite + return ordering == isinstance(filt, CalumaOrdering) + filter_fields = { name: _get_or_make_field(name, filt) for name, filt in _filter_coll.filters.items() - if not isinstance(filt, OrderingFilter) + # exclude orderBy in our fields. We want only new-style order filters + if _should_include_filter(filt) and name != "orderBy" } - filter_fields["invert"] = graphene.Boolean(required=False, default=False) + if ordering: + filter_fields["direction"] = AscDesc(default=AscDesc.ASC, required=False) + else: + filter_fields["invert"] = graphene.Boolean(required=False, default=False) filter_type = type(field_type_name, (InputObjectType,), filter_fields) @@ -138,14 +193,26 @@ def _get_or_make_field(name, filt): registry.register_converted_field(field, converted) return converted - return FilterCollection() + filter_impl = FilterCollectionOrdering if ordering else FilterCollectionFilter + + filter_coll = type( + collection_name, + (filter_impl,), + {"field_class": custom_field_class, "filterset_class": filterset_class}, + ) + return filter_coll() -def CollectionFilterSetFactory(filterset_class): +def CollectionFilterSetFactory(filterset_class, orderset_class=None): """ Build single-filter filterset classes. - Use this in place of a regular filterset_class parametrisation. + Use this in place of a regular filterset_class parametrisation in + the serializers. + If you pass the optional `orderset_class` parameter, it is used for + an `order` filter. The filters defined in the `orderset_class` must + inherit from `caluma.core.ordering.CalumaOrdering` and provide a + `get_ordering_value()` method. Example: >>> all_documents = DjangoFilterConnectionField( @@ -154,20 +221,27 @@ def CollectionFilterSetFactory(filterset_class): """ - if filterset_class.__name__ in CollectionFilterSetFactory._cache: - return CollectionFilterSetFactory._cache[filterset_class.__name__] + suffix = "" if orderset_class else "NoOrdering" + cache_key = filterset_class.__name__ + suffix + + if cache_key in CollectionFilterSetFactory._cache: + return CollectionFilterSetFactory._cache[cache_key] + + coll_fields = {"filter": FilterCollectionFactory(filterset_class, ordering=False)} + if orderset_class: + coll_fields["order"] = FilterCollectionFactory(orderset_class, ordering=True) - ret = CollectionFilterSetFactory._cache[filterset_class.__name__] = type( + ret = CollectionFilterSetFactory._cache[cache_key] = type( f"{filterset_class.__name__}Collection", (filterset_class, FilterSet), { - "filter": FilterCollectionFactory(filterset_class), + **coll_fields, "Meta": type( "Meta", (filterset_class.Meta,), { "model": filterset_class.Meta.model, - "fields": filterset_class.Meta.fields + ("filter",), + "fields": filterset_class.Meta.fields + tuple(coll_fields.keys()), }, ), }, diff --git a/caluma/core/ordering.py b/caluma/core/ordering.py new file mode 100644 index 000000000..186d8a072 --- /dev/null +++ b/caluma/core/ordering.py @@ -0,0 +1,101 @@ +from typing import Any, Tuple, Union + +from django import forms +from django.db.models.expressions import CombinedExpression, F, Value +from django.db.models.query import QuerySet +from django_filters.fields import ChoiceField +from django_filters.rest_framework import Filter +from graphene import Enum +from graphene_django.forms.converter import convert_form_field +from graphene_django.registry import get_global_registry +from rest_framework import exceptions + +OrderingFieldType = Union[F, str] + + +class CalumaOrdering(Filter): + """Base class for new-style ordering filters. + + This is not really a filter in the rest framework sense, + but we use it's infrastructure to get conversion to GraphQL + types. + + This is an abstract base class. To use it, you need to + implement the `get_ordering_value()` method to return a tuple + of `(queryset, ordering_field)`. The queryset may be modified, + for example if you need to annotate it to get to the desired + ordering field. + """ + + def get_ordering_value( + self, qs: QuerySet, value: Any + ) -> Tuple[QuerySet, OrderingFieldType]: # pragma: no cover + raise NotImplementedError("get_ordering_value() needs to be implemented") + + +class MetaFieldOrdering(CalumaOrdering): + """Ordering filter for ordering by `meta` values.""" + + def __init__(self, field_name="meta"): + super().__init__() + self.field_name = field_name + + field_class = forms.CharField + + def get_ordering_value( + self, qs: QuerySet, value: Any + ) -> Tuple[QuerySet, OrderingFieldType]: + + return qs, CombinedExpression(F(self.field_name), "->", Value(value)) + + +class AttributeOrderingMixin(CalumaOrdering): + def get_ordering_value(self, qs, value): + if value not in self._fields: # pragma: no cover + # this is normally covered by graphene enforcing its schema, + # but we still need to handle it + raise exceptions.ValidationError( + f"Field '{value}' not available for sorting on {qs.model.__name}" + ) + return qs, F(value) + + +def AttributeOrderingFactory(model, fields=None, exclude_fields=None): + """Build ordering field for a given model. + + Used to define an ordering field that is presented as an enum + """ + if not exclude_fields: + exclude_fields = [] + + if not fields: + fields = [f.name for f in model._meta.fields if f.name not in exclude_fields] + + field_enum = type( + f"Sortable{model.__name__}Attributes", + (Enum,), + {field.upper(): field for field in fields}, + ) + + field_class = type(f"{model.__name__}AttributeField", (ChoiceField,), {}) + + ordering_class = type( + f"{model.__name__}AttributeOrdering", + (AttributeOrderingMixin, CalumaOrdering), + {"field_class": field_class, "_fields": fields}, + ) + + @convert_form_field.register(field_class) + def to_enum(field): + registry = get_global_registry() + converted = registry.get_converted_field(field) + + if converted: # pragma: no cover + return converted + + converted = field_enum() + + registry.register_converted_field(field, converted) + return converted + + return ordering_class() diff --git a/caluma/form/ordering.py b/caluma/form/ordering.py new file mode 100644 index 000000000..4d24cff08 --- /dev/null +++ b/caluma/form/ordering.py @@ -0,0 +1,65 @@ +from typing import Any, Tuple + +from django import forms +from django.db.models import OuterRef, Subquery +from django.db.models.expressions import F +from django.db.models.query import QuerySet +from rest_framework import exceptions + +from caluma.core.ordering import CalumaOrdering, OrderingFieldType +from caluma.form.models import Answer, Question + + +class AnswerValueOrdering(CalumaOrdering): + """Ordering filter for ordering documents by value of an answer. + + This can also be used for ordering other things that contain documents, + such as cases, work items etc. + """ + + field_class = forms.CharField + + def __init__(self, document_via=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._document_locator_prefix = ( + "" if document_via is None else f"{document_via}__" + ) + + def get_ordering_value( + self, qs: QuerySet, value: Any + ) -> Tuple[QuerySet, OrderingFieldType]: + # First, join the requested answer, then annotate the QS accordingly. + # Last, return a field corresponding to the value + # + question = Question.objects.get(pk=value) + QUESTION_TYPE_TO_FIELD = { + Question.TYPE_INTEGER: "value", + Question.TYPE_FLOAT: "value", + Question.TYPE_DATE: "date", + Question.TYPE_CHOICE: "value", + Question.TYPE_TEXTAREA: "value", + Question.TYPE_TEXT: "value", + Question.TYPE_FILE: "file", + Question.TYPE_DYNAMIC_CHOICE: "value", + Question.TYPE_STATIC: "value", + } + + try: + value_field = QUESTION_TYPE_TO_FIELD[question.type] + except KeyError: # pragma: no cover + raise exceptions.ValidationError( + f"Question '{question.slug}' has unsupported type {question.type} for ordering" + ) + + answers_subquery = Subquery( + Answer.objects.filter(question=question, document=OuterRef("pk")).values( + value_field + ) + ) + ann_name = f"order_{value}" + + qs = qs.annotate(**{ann_name: answers_subquery}) + + # TODO: respect document_via + return qs, F(ann_name)