Enhancement: relative date custom field queries (#14549)

This commit is contained in:
shamoon authored and GitHub committed 2026-10-29 17:25:21 +00:00
1 parent c21bdab5c8
commit 67c194978e
7 files changed
+353 -19

No files matched your search

+53
View File
@@ -11,6 +11,8 @@ from decimal import InvalidOperation
from typing import TYPE_CHECKING
from typing import Any
import dateparser
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import CharField
@@ -25,6 +27,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 +60,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 +326,51 @@ 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,
# The UI always builds English expressions
locales=[*settings.DATE_PARSER_LANGUAGES, "en"]
if settings.DATE_PARSER_LANGUAGES
else None,
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 +512,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 +700,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())
@@ -2,8 +2,12 @@ import json
import types
from collections.abc import Callable
from datetime import date
from unittest.mock import patch
from urllib.parse import quote
import time_machine
from django.test import override_settings
from django.utils import timezone
from rest_framework.test import APITestCase
from documents.models import CustomField
@@ -443,6 +447,65 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
),
)
@override_settings(TIME_ZONE="America/Los_Angeles")
def test_relative_dates(self) -> None:
# Reevaluate the same mixed relative/fixed range on different local dates.
for today in (date(2024, 8, 22), date(2024, 11, 15)):
with (
self.subTest(today=today),
time_machine.travel(f"{today}T23:30:00-07:00", tick=False),
patch(
"documents.filters.timezone.localtime",
wraps=timezone.localtime,
) as localtime,
):
self._assert_query_match_predicate(
["date_field", "range", ["today", "2024-11-15"]],
lambda document, today=today: (
"date_field" in document
and document["date_field"] is not None
and today <= document["date_field"] <= date(2024, 11, 15)
),
)
localtime.assert_called_once_with()
def test_relative_date_validation(self) -> None:
for value, keyword in (
("not a date", "date"),
("January 2024", "date"),
("today + " + "9" * 5000 + " days", "date"),
(True, "format"),
):
with self.subTest(value=str(value)[:50]):
self._assert_validation_error(
json.dumps(["date_field", "exact", value]),
["custom_field_query", "2"],
keyword,
)
@time_machine.travel("2024-08-22T12:00:00Z", tick=False)
def test_relative_date_languages(self) -> None:
for locales in (["de"], ["zh-Hans", "de"], None):
with (
self.subTest(locales=locales),
override_settings(
DATE_PARSER_LANGUAGES=locales,
),
):
self._assert_query_match_predicate(
["date_field", "exact", "heute"],
lambda document: (
"date_field" in document
and document["date_field"] == date(2024, 8, 22)
),
)
with override_settings(DATE_PARSER_LANGUAGES=["en"]):
self._assert_validation_error(
json.dumps(["date_field", "exact", "heute"]),
["custom_field_query", "2"],
"date",
)
def test_gt_monetary(self) -> None:
self._assert_query_match_predicate(
["monetary_field", "gt", "99"],