diff --git a/dje/utils.py b/dje/utils.py index 08bbfdd9..5a9ef21e 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -692,6 +692,17 @@ def humanize_time(seconds): return message +def parse_date_aware(value): + """ + Parse a date or datetime string and return a timezone-aware datetime. + Supports both "2025-01-01" and "2025-01-01 14:30:00" formats. + """ + dt = parse_datetime(value) + if dt and timezone.is_naive(dt): + dt = timezone.make_aware(dt) + return dt + + def localized_datetime(datetime): """ Format a given datetime string into the application's default display format, diff --git a/reporting/fields.py b/reporting/fields.py index d042e41b..fec089aa 100644 --- a/reporting/fields.py +++ b/reporting/fields.py @@ -12,7 +12,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from dateutil import parser +import dateutil class BooleanSelect(Select): @@ -102,12 +102,8 @@ def __init__(self, attrs=None, choices=()): @staticmethod def _get_today(): - now = timezone.now() - # When time zone support is enabled, convert "now" to the user's time - # zone so Django's definition of "Today" matches what the user expects. - if timezone.is_aware(now): - now = timezone.localtime(now) - return now.replace(hour=0, minute=0, second=0, microsecond=0) + """Get today's date at midnight in the current timezone.""" + return timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) def render(self, name, value, attrs=None, renderer=None): if not value: @@ -115,7 +111,10 @@ def render(self, name, value, attrs=None, renderer=None): value = "ERROR" try: - value_as_date = parser.parse(value) + value_as_date = dateutil.parser.parse(value) + # Make the parsed datetime timezone-aware to match "today" value + if timezone.is_naive(value_as_date): + value_as_date = timezone.make_aware(value_as_date) except Exception: value = "any_date" else: diff --git a/reporting/models.py b/reporting/models.py index f082656a..39a9880e 100644 --- a/reporting/models.py +++ b/reporting/models.py @@ -37,6 +37,7 @@ from dje.models import is_secured from dje.models import secure_queryset_relational_fields from dje.utils import extract_name_version +from dje.utils import parse_date_aware from reporting.fields import DATE_FILTER_CHOICES from reporting.fields import BooleanSelect from reporting.fields import DateFieldFilterSelect @@ -358,6 +359,9 @@ def get_coerced_value(self, value): final_part = field_parts[-1] model_field_instance = model._meta.get_field(final_part) + if isinstance(model_field_instance, models.DateField): + value = parse_date_aware(value) + # For non-RelatedFields use the model field's form field to # coerce the value to a Python object if not isinstance(model_field_instance, RelatedField): @@ -368,10 +372,9 @@ def get_coerced_value(self, value): # is required to run the custom validators declared on the # Model field. model_field_instance.clean(value, model) - # We must check if ``fields_for_model()`` Return the field - # we are considering. For example ``AutoField`` returns - # None for its form field and thus will not be in the - # dictionary returned by ``fields_for_model()``. + # We must check if ``fields_for_model()`` Return the field we are considering. + # For example ``AutoField`` returns None for its form field and thus will not + # be in the dictionary returned by ``fields_for_model()``. form_field_instance = fields_for_model(model).get(final_part) if form_field_instance: widget_value = form_field_instance.widget.value_from_datadict( @@ -410,7 +413,7 @@ def get_q(self, runtime_value=None, user=None): if value == BooleanSelect.ALL_CHOICE_VALUE: return - # Hack to support special values for date filtering, see #9049 + # Hack to support special values for date filtering, such as "past_7_days" if value in [choice[0] for choice in DATE_FILTER_CHOICES]: value = DateFieldFilterSelect().value_from_datadict( data={"value": value}, files=None, name="value" diff --git a/reporting/tests/test_models.py b/reporting/tests/test_models.py index ba4a4d74..3adc1c8c 100644 --- a/reporting/tests/test_models.py +++ b/reporting/tests/test_models.py @@ -7,6 +7,7 @@ # import datetime +import zoneinfo from unittest.util import safe_repr from django.contrib.contenttypes.models import ContentType @@ -782,8 +783,27 @@ def test_get_coerced_value(self): lookup="exact", value="True", ) + self.assertEqual(True, f.get_coerced_value(f.value)) - expected = True + def test_get_coerced_value_date_field(self): + query = Query.objects.create( + dataspace=self.dataspace, + name="Date", + content_type=self.license_ct, + operator="and", + ) + f = Filter.objects.create( + dataspace=self.dataspace, + query=query, + field_name="last_modified_date", + lookup="gte", + value="2025-01-01", + ) + expected = datetime.datetime(2025, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC")) + self.assertEqual(expected, f.get_coerced_value(f.value)) + + f.update(value="2025-01-01 14:30:00") + expected = datetime.datetime(2025, 1, 1, 14, 30, tzinfo=zoneinfo.ZoneInfo(key="UTC")) self.assertEqual(expected, f.get_coerced_value(f.value)) def test_get_coerced_value_validation_from_model_validators(self): @@ -905,12 +925,9 @@ def test_get_q_for_date_field_filter(self): today = DateFieldFilterSelect._get_today() past_7_days = today - datetime.timedelta(days=7) - self.assertEqual([("last_modified_date__gte", str(past_7_days))], f.get_q().children) - - self.assertEqual([("last_modified_date__gte", str(today))], f.get_q("today").children) - - with self.assertRaises(ValidationError): - f.get_q("invalid").children + self.assertEqual([("last_modified_date__gte", past_7_days)], f.get_q().children) + self.assertEqual([("last_modified_date__gte", today)], f.get_q("today").children) + self.assertEqual([("last_modified_date__gte", None)], f.get_q("invalid").children) def test_get_q_for_boolean_select_all_choice_value(self): query = Query.objects.create( diff --git a/reporting/tests/test_views.py b/reporting/tests/test_views.py index 7949815e..9a4a3847 100644 --- a/reporting/tests/test_views.py +++ b/reporting/tests/test_views.py @@ -370,6 +370,31 @@ def test_run_report_view_lookup_displayed_value(self): response = self.client.get(self.report.get_absolute_url()) self.assertContains(response, "Case-insensitive exact match.") + def test_run_report_view_date_field_filter_value(self): + self.client.login(username="test", password="t3st") + query = Query.objects.create( + dataspace=self.dataspace, + name="License activity", + content_type=ContentType.objects.get_for_model(License), + operator="or", + ) + Filter.objects.create( + dataspace=self.dataspace, + query=query, + field_name="last_modified_date", + lookup="gte", + value="2025-01-01", + runtime_parameter=True, + ) + report = Report.objects.create( + name="License activity", + query=query, + column_template=self.column_template, + ) + + response = self.client.get(report.get_absolute_url()) + self.assertEqual(200, response.status_code) + def test_run_report_view_results_count(self): self.client.login(username="test", password="t3st") response = self.client.get(self.report.get_absolute_url())