Skip to content

Commit 0e2fe0b

Browse files
committed
fix: parsing of str into timezone aware dates in reporting
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 6051ac3 commit 0e2fe0b

5 files changed

Lines changed: 75 additions & 20 deletions

File tree

dje/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,17 @@ def humanize_time(seconds):
692692
return message
693693

694694

695+
def parse_date_aware(value):
696+
"""
697+
Parse a date or datetime string and return a timezone-aware datetime.
698+
Supports both "2025-01-01" and "2025-01-01 14:30:00" formats.
699+
"""
700+
dt = parse_datetime(value)
701+
if dt and timezone.is_naive(dt):
702+
dt = timezone.make_aware(dt)
703+
return dt
704+
705+
695706
def localized_datetime(datetime):
696707
"""
697708
Format a given datetime string into the application's default display format,

reporting/fields.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from django.utils import timezone
1313
from django.utils.translation import gettext_lazy as _
1414

15-
from dateutil import parser
15+
import dateutil
1616

1717

1818
class BooleanSelect(Select):
@@ -102,20 +102,19 @@ def __init__(self, attrs=None, choices=()):
102102

103103
@staticmethod
104104
def _get_today():
105-
now = timezone.now()
106-
# When time zone support is enabled, convert "now" to the user's time
107-
# zone so Django's definition of "Today" matches what the user expects.
108-
if timezone.is_aware(now):
109-
now = timezone.localtime(now)
110-
return now.replace(hour=0, minute=0, second=0, microsecond=0)
105+
"""Get today's date at midnight in the current timezone."""
106+
return timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
111107

112108
def render(self, name, value, attrs=None, renderer=None):
113109
if not value:
114110
# Force an Exception, empty string Return today in the parser
115111
value = "ERROR"
116112

117113
try:
118-
value_as_date = parser.parse(value)
114+
value_as_date = dateutil.parser.parse(value)
115+
# Make the parsed datetime timezone-aware to match "today" value
116+
if timezone.is_naive(value_as_date):
117+
value_as_date = timezone.make_aware(value_as_date)
119118
except Exception:
120119
value = "any_date"
121120
else:

reporting/models.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from dje.models import is_secured
3838
from dje.models import secure_queryset_relational_fields
3939
from dje.utils import extract_name_version
40+
from dje.utils import parse_date_aware
4041
from reporting.fields import DATE_FILTER_CHOICES
4142
from reporting.fields import BooleanSelect
4243
from reporting.fields import DateFieldFilterSelect
@@ -358,6 +359,9 @@ def get_coerced_value(self, value):
358359
final_part = field_parts[-1]
359360
model_field_instance = model._meta.get_field(final_part)
360361

362+
if isinstance(model_field_instance, models.DateField):
363+
value = parse_date_aware(value)
364+
361365
# For non-RelatedFields use the model field's form field to
362366
# coerce the value to a Python object
363367
if not isinstance(model_field_instance, RelatedField):
@@ -368,10 +372,9 @@ def get_coerced_value(self, value):
368372
# is required to run the custom validators declared on the
369373
# Model field.
370374
model_field_instance.clean(value, model)
371-
# We must check if ``fields_for_model()`` Return the field
372-
# we are considering. For example ``AutoField`` returns
373-
# None for its form field and thus will not be in the
374-
# dictionary returned by ``fields_for_model()``.
375+
# We must check if ``fields_for_model()`` Return the field we are considering.
376+
# For example ``AutoField`` returns None for its form field and thus will not
377+
# be in the dictionary returned by ``fields_for_model()``.
375378
form_field_instance = fields_for_model(model).get(final_part)
376379
if form_field_instance:
377380
widget_value = form_field_instance.widget.value_from_datadict(
@@ -410,7 +413,7 @@ def get_q(self, runtime_value=None, user=None):
410413
if value == BooleanSelect.ALL_CHOICE_VALUE:
411414
return
412415

413-
# Hack to support special values for date filtering, see #9049
416+
# Hack to support special values for date filtering, such as "past_7_days"
414417
if value in [choice[0] for choice in DATE_FILTER_CHOICES]:
415418
value = DateFieldFilterSelect().value_from_datadict(
416419
data={"value": value}, files=None, name="value"

reporting/tests/test_models.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#
88

99
import datetime
10+
import zoneinfo
1011
from unittest.util import safe_repr
1112

1213
from django.contrib.contenttypes.models import ContentType
@@ -782,8 +783,27 @@ def test_get_coerced_value(self):
782783
lookup="exact",
783784
value="True",
784785
)
786+
self.assertEqual(True, f.get_coerced_value(f.value))
785787

786-
expected = True
788+
def test_get_coerced_value_date_field(self):
789+
query = Query.objects.create(
790+
dataspace=self.dataspace,
791+
name="Date",
792+
content_type=self.license_ct,
793+
operator="and",
794+
)
795+
f = Filter.objects.create(
796+
dataspace=self.dataspace,
797+
query=query,
798+
field_name="last_modified_date",
799+
lookup="gte",
800+
value="2025-01-01",
801+
)
802+
expected = datetime.datetime(2025, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC"))
803+
self.assertEqual(expected, f.get_coerced_value(f.value))
804+
805+
f.update(value="2025-01-01 14:30:00")
806+
expected = datetime.datetime(2025, 1, 1, 14, 30, tzinfo=zoneinfo.ZoneInfo(key="UTC"))
787807
self.assertEqual(expected, f.get_coerced_value(f.value))
788808

789809
def test_get_coerced_value_validation_from_model_validators(self):
@@ -905,12 +925,9 @@ def test_get_q_for_date_field_filter(self):
905925

906926
today = DateFieldFilterSelect._get_today()
907927
past_7_days = today - datetime.timedelta(days=7)
908-
self.assertEqual([("last_modified_date__gte", str(past_7_days))], f.get_q().children)
909-
910-
self.assertEqual([("last_modified_date__gte", str(today))], f.get_q("today").children)
911-
912-
with self.assertRaises(ValidationError):
913-
f.get_q("invalid").children
928+
self.assertEqual([("last_modified_date__gte", past_7_days)], f.get_q().children)
929+
self.assertEqual([("last_modified_date__gte", today)], f.get_q("today").children)
930+
self.assertEqual([("last_modified_date__gte", None)], f.get_q("invalid").children)
914931

915932
def test_get_q_for_boolean_select_all_choice_value(self):
916933
query = Query.objects.create(

reporting/tests/test_views.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,31 @@ def test_run_report_view_lookup_displayed_value(self):
370370
response = self.client.get(self.report.get_absolute_url())
371371
self.assertContains(response, "Case-insensitive exact match.")
372372

373+
def test_run_report_view_date_field_filter_value(self):
374+
self.client.login(username="test", password="t3st")
375+
query = Query.objects.create(
376+
dataspace=self.dataspace,
377+
name="License activity",
378+
content_type=ContentType.objects.get_for_model(License),
379+
operator="or",
380+
)
381+
Filter.objects.create(
382+
dataspace=self.dataspace,
383+
query=query,
384+
field_name="last_modified_date",
385+
lookup="gte",
386+
value="2025-01-01",
387+
runtime_parameter=True,
388+
)
389+
report = Report.objects.create(
390+
name="License activity",
391+
query=query,
392+
column_template=self.column_template,
393+
)
394+
395+
response = self.client.get(report.get_absolute_url())
396+
self.assertEqual(200, response.status_code)
397+
373398
def test_run_report_view_results_count(self):
374399
self.client.login(username="test", password="t3st")
375400
response = self.client.get(self.report.get_absolute_url())

0 commit comments

Comments
 (0)