diff --git a/src/documents/filters.py b/src/documents/filters.py index a95e3b288..f17f70174 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -11,6 +11,7 @@ from decimal import InvalidOperation from typing import TYPE_CHECKING from typing import Any +import dateparser from django.contrib.contenttypes.models import ContentType from django.db.models import Case from django.db.models import CharField @@ -25,6 +26,7 @@ from django.db.models import Value from django.db.models import When from django.db.models.functions import Cast from django.db.models.functions import NullIf +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_filters import DateFilter from django_filters.rest_framework import BooleanFilter @@ -57,6 +59,7 @@ from documents.versioning import annotate_effective_content if TYPE_CHECKING: from collections.abc import Callable + from datetime import datetime CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] ID_KWARGS = ["in", "exact"] @@ -322,6 +325,48 @@ class MonetaryAmountField(serializers.Field): return str(value) +class DateQueryField(serializers.DateField): + """ + Accepts either a fixed date or a relative date expression using `dateparser` and + resolves it against the provided reference datetime. + """ + + default_error_messages = { + "expression": _( + 'Expected a date or a relative date such as "today" or "in 30 days".', + ), + } + + def __init__(self, *, reference_datetime: datetime, **kwargs): + super().__init__(**kwargs) + self.reference_datetime = reference_datetime + + def to_internal_value(self, value): + try: + # Let the parent class handle fixed-date formats and string validation first + return super().to_internal_value(value) + except serializers.ValidationError: + if not isinstance(value, str): + raise + + try: + parsed = dateparser.parse( + value, + languages=["en"], + settings={ + "PARSERS": ["relative-time"], + "RELATIVE_BASE": self.reference_datetime, + "TIMEZONE": str(self.reference_datetime.tzinfo), + "RETURN_AS_TIMEZONE_AWARE": True, + }, + ) + except (ValueError, OverflowError): + self.fail("expression") + if parsed is None: + self.fail("expression") + return parsed.astimezone(self.reference_datetime.tzinfo).date() + + class SelectField(serializers.CharField): def __init__(self, custom_field: CustomField) -> None: self._options = custom_field.extra_data["select_options"] @@ -463,6 +508,8 @@ class CustomFieldQueryParser: raise serializers.ValidationError( {self._validation_prefix: [_("Value must be valid JSON.")]}, ) + # Save the current local time as the reference for the query + self._reference_datetime = timezone.localtime() return ( self._parse_expr(expr, validation_prefix=self._validation_prefix), self._annotations, @@ -649,6 +696,8 @@ class CustomFieldQueryParser: ): # DateField admits queries in the form of `year__exact`, etc. These take integers. field = serializers.IntegerField() + elif custom_field.data_type == CustomField.FieldDataType.DATE: + field = DateQueryField(reference_datetime=self._reference_datetime) elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: # We can be more specific here and make sure the value is a list. field = serializers.ListField(child=serializers.IntegerField())