mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-30 03:25:43 +00:00
Enhancement: relative date custom field queries (#14549)
This commit is contained in:
1 parent
c21bdab5c8
commit
67c194978e
7 files changed
+353
-19
No files matched your search
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user