diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7a684c3c..19634b3f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -83,6 +83,22 @@ Release notes - Enable the delete_selected action on RequestTemplateAdmin. https://github.com/aboutcode-org/dejacode/issues/243 +- The data rendering format was simplified for improved readability from + "Jan. 27, 2025, 07:55:54 a.m. UTC" to "Jan 27, 2025, 7:55 AM UTC". + The dates are now always rendered using this same format across the app. + The user timezone is automatically discovered and activated to the whole app using + the browser JavaScript `timeZone` API + The user's automatic timezone can be overridden using the new + ``DejacodeUser.timezone`` database field. + The timezone value can be defined from the User > "Profile Settings" form. + This value always takes precedence when defined. + In case the timezone is not defined by the user, or cannot be detected from the + browser, the date rendering always fallback to UTC. + Note: all the "humanized dates" such as "Modified 23 hours ago" have the whole + date syntax available in their `title` option, available on hovering the text with + the cursor for a couple seconds. + https://github.com/aboutcode-org/dejacode/issues/243 + ### Version 5.2.1 - Fix the models documentation navigation. diff --git a/component_catalog/templates/component_catalog/includes/scan_list_table.html b/component_catalog/templates/component_catalog/includes/scan_list_table.html index 650e2b8f..9c420e11 100644 --- a/component_catalog/templates/component_catalog/includes/scan_list_table.html +++ b/component_catalog/templates/component_catalog/includes/scan_list_table.html @@ -36,7 +36,7 @@
- Created {{ scan.created_date|naturaltime }} + Created {{ scan.created_date|naturaltime }}
diff --git a/component_catalog/templates/component_catalog/tables/component_list_table.html b/component_catalog/templates/component_catalog/tables/component_list_table.html index f2aee581..6390b5f6 100644 --- a/component_catalog/templates/component_catalog/tables/component_list_table.html +++ b/component_catalog/templates/component_catalog/tables/component_list_table.html @@ -48,10 +48,10 @@ {{ object.name }} {% if filter.show_created_date %} -
Created {{ object.created_date|naturaltime_short }}
+
Created {{ object.created_date|naturaltime_short }}
{% endif %} {% if filter.show_last_modified_date %} -
Modified {{ object.last_modified_date|naturaltime_short }}
+
Modified {{ object.last_modified_date|naturaltime_short }}
{% endif %} diff --git a/component_catalog/templates/component_catalog/tables/package_list_table.html b/component_catalog/templates/component_catalog/tables/package_list_table.html index 59351ec3..2fc961b9 100644 --- a/component_catalog/templates/component_catalog/tables/package_list_table.html +++ b/component_catalog/templates/component_catalog/tables/package_list_table.html @@ -35,10 +35,10 @@ {% if filter.show_created_date %} -
Created {{ object.created_date|naturaltime_short }}
+
Created {{ object.created_date|naturaltime_short }}
{% endif %} {% if filter.show_last_modified_date %} -
Modified {{ object.last_modified_date|naturaltime_short }}
+
Modified {{ object.last_modified_date|naturaltime_short }}
{% endif %} {% if dataspace.show_usage_policy_in_user_views %} diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py index 2751179c..7015ae22 100644 --- a/component_catalog/tests/test_views.py +++ b/component_catalog/tests/test_views.py @@ -2079,14 +2079,16 @@ def test_package_details_view_scan_tab_scan_in_progress(
-            June 21, 2018, 12:32 PM UTC
+            Jun 21, 2018, 12:32 PM UTC
           
Start date
-
June 21, 2018, 12:32 PM UTC
+
+            Jun 21, 2018, 12:32 PM UTC
+          
End date
diff --git a/component_catalog/views.py b/component_catalog/views.py index d3f79912..14b41fa0 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -32,7 +32,6 @@ from django.urls import reverse from django.urls import reverse_lazy from django.utils.dateparse import parse_datetime -from django.utils.formats import date_format from django.utils.html import escape from django.utils.html import format_html from django.utils.text import normalize_newlines @@ -86,6 +85,7 @@ from dje.utils import get_preserved_filters from dje.utils import is_available from dje.utils import is_uuid4 +from dje.utils import localized_datetime from dje.utils import remove_empty_values from dje.utils import str_to_id_list from dje.views import AcceptAnonymousMixin @@ -1375,11 +1375,6 @@ def tab_aboutcode(self): return {"fields": [(None, context, None, template)]} - @staticmethod - def readable_date(date): - if date: - return date_format(parse_datetime(date), "N j, Y, f A T") - def post_scan_to_package(self, form_class): request = self.request @@ -2299,11 +2294,6 @@ def scan_summary_fields(self, scan_summary): scan_summary_fields.append(FieldSeparator) return scan_summary_fields - @staticmethod - def readable_date(date): - if date: - return date_format(parse_datetime(date), "N j, Y, f A T") - def scan_status_fields(self, scan): scan_status_fields = [] scan_run = scan.get("runs", [{}])[-1] @@ -2329,9 +2319,9 @@ def scan_status_fields(self, scan): scan_status_fields.extend( [ ("Status", f"Scan {status}"), - ("Created date", self.readable_date(scan_run.get("created_date"))), - ("Start date", self.readable_date(scan_run.get("task_start_date"))), - ("End date", self.readable_date(scan_run.get("task_end_date"))), + ("Created date", localized_datetime(scan_run.get("created_date"))), + ("Start date", localized_datetime(scan_run.get("task_start_date"))), + ("End date", localized_datetime(scan_run.get("task_end_date"))), ("ScanCode.io version", scan_run.get("scancodeio_version")), ] ) diff --git a/dejacode/formats/cu/formats.py b/dejacode/formats/cu/formats.py index 90ca1a4b..aadeb2bf 100644 --- a/dejacode/formats/cu/formats.py +++ b/dejacode/formats/cu/formats.py @@ -6,5 +6,5 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -DATE_FORMAT = "N d, Y" -DATETIME_FORMAT = "N d, Y, h:i:s a T" +DATE_FORMAT = "M d, Y" +DATETIME_FORMAT = "M d, Y, g:i A T" diff --git a/dejacode/formats/en/formats.py b/dejacode/formats/en/formats.py index 90ca1a4b..aadeb2bf 100644 --- a/dejacode/formats/en/formats.py +++ b/dejacode/formats/en/formats.py @@ -6,5 +6,5 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -DATE_FORMAT = "N d, Y" -DATETIME_FORMAT = "N d, Y, h:i:s a T" +DATE_FORMAT = "M d, Y" +DATETIME_FORMAT = "M d, Y, g:i A T" diff --git a/dejacode/settings.py b/dejacode/settings.py index e84566f9..14957c02 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -89,7 +89,7 @@ # although not all choices may be available on all operating systems. # On Unix systems, a value of None will cause Django to use the same # timezone as the operating system. -TIME_ZONE = env.str("TIME_ZONE", default="US/Pacific") +TIME_ZONE = env.str("TIME_ZONE", default="UTC") SITE_URL = env.str("SITE_URL", default="") @@ -187,6 +187,7 @@ def gettext_noop(s): # OTPMiddleware needs to come after AuthenticationMiddleware "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", + "dje.middleware.TimezoneMiddleware", "dje.middleware.LastAPIAccessMiddleware", *EXTRA_MIDDLEWARE, # AxesMiddleware should be the last middleware in the MIDDLEWARE list. diff --git a/dje/fields.py b/dje/fields.py index bc88c2c1..ea11a183 100644 --- a/dje/fields.py +++ b/dje/fields.py @@ -6,6 +6,9 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import zoneinfo +from datetime import datetime + from django import forms from django.conf import settings from django.db import models @@ -202,3 +205,84 @@ class JSONListField(models.JSONField): def __init__(self, *args, **kwargs): kwargs["default"] = list super().__init__(*args, **kwargs) + + +class TimeZoneChoiceField(forms.ChoiceField): + """Field for selecting time zones, displaying human-readable names with offsets.""" + + STANDARD_TIMEZONE_NAMES = { + "America/Argentina": "Argentina Standard Time", + "America/Indiana": "Indiana Time", + "America/Kentucky": "Kentucky Time", + "America/North_Dakota": "North Dakota Time", + } + + def __init__(self, *args, **kwargs): + choices = [("", "Select Time Zone")] + timezone_groups = {} + + # Collect timezones with offsets + for tz in sorted(zoneinfo.available_timezones()): + skip_list = ("Etc/GMT", "GMT", "PST", "CST", "EST", "MST", "UCT", "UTC", "WET", "MET") + if tz.startswith(skip_list): + continue + + offset_hours, formatted_offset = self.get_timezone_offset(tz) + standard_name, city = self.get_timezone_parts(tz) + + formatted_name = f"{formatted_offset}" + if standard_name: + formatted_name += f" {standard_name} - {city}" + else: + formatted_name += f" {city}" + + if offset_hours not in timezone_groups: + timezone_groups[offset_hours] = [] + timezone_groups[offset_hours].append((tz, formatted_name)) + + # Sort within each offset group and compile final sorted choices + sorted_offsets = sorted(timezone_groups.keys()) # Sort by GMT offset + for offset in sorted_offsets: + for tz, formatted_name in sorted(timezone_groups[offset], key=lambda x: x[1]): + choices.append((tz, formatted_name)) + + kwargs["choices"] = choices + super().__init__(*args, **kwargs) + + @staticmethod + def get_timezone_offset(tz): + """Return the numeric offset for sorting and formatted offset for display.""" + tz_info = zoneinfo.ZoneInfo(tz) + now = datetime.now(tz_info) + offset_seconds = now.utcoffset().total_seconds() + offset_hours = offset_seconds / 3600 + + # Format offset as GMT+XX:XX or GMT-XX:XX + sign = "+" if offset_hours >= 0 else "-" + hours = int(abs(offset_hours)) + minutes = int((abs(offset_hours) - hours) * 60) + formatted_offset = f"(GMT{sign}{hours:02}:{minutes:02})" + + return offset_hours, formatted_offset + + @classmethod + def get_standard_timezone_name(cls, tz): + """Return a human-friendly standard timezone name if available.""" + for key in cls.STANDARD_TIMEZONE_NAMES: + if tz.startswith(key): + return cls.STANDARD_TIMEZONE_NAMES[key] + + if default := tz.split("/")[0]: + return default + + @classmethod + def get_timezone_parts(cls, tz): + """Extract standard timezone name and city for display.""" + parts = tz.split("/") + region = "/".join(parts[:-1]) # Everything except last part + city = parts[-1].replace("_", " ") # City with spaces + + # Get human-readable timezone name + standard_name = cls.get_standard_timezone_name(region) + + return standard_name, city diff --git a/dje/forms.py b/dje/forms.py index 460bb957..417d8a5e 100644 --- a/dje/forms.py +++ b/dje/forms.py @@ -36,6 +36,7 @@ from dje.copier import copy_object from dje.copier import get_copy_defaults from dje.copier import get_object_in +from dje.fields import TimeZoneChoiceField from dje.models import Dataspace from dje.models import DataspaceConfiguration from dje.models import History @@ -43,6 +44,7 @@ from dje.models import is_dataspace_related from dje.permissions import get_all_tabsets from dje.permissions import get_protected_fields +from dje.utils import get_help_text from dje.utils import has_permission UserModel = get_user_model() @@ -256,6 +258,11 @@ def identifier_fields(self): class AccountProfileForm(ScopeAndProtectRelationships, forms.ModelForm): username = forms.CharField(disabled=True, required=False) email = forms.CharField(disabled=True, required=False) + timezone = TimeZoneChoiceField( + label=_("Time zone"), + required=False, + widget=forms.Select(attrs={"aria-label": "Select Time Zone"}), + ) class Meta: model = UserModel @@ -268,11 +275,13 @@ class Meta: "workflow_email_notification", "updates_email_notification", "homepage_layout", + "timezone", ] def __init__(self, *args, **kwargs): self.user = kwargs.get("instance") super().__init__(*args, **kwargs) + self.fields["timezone"].help_text = get_help_text(self._meta.model, "timezone") def save(self, commit=True): instance = super().save(commit) @@ -295,7 +304,7 @@ def helper(self): # of the form, for security purposes. dataspace_field = HTML( f""" -
+
@@ -312,17 +321,9 @@ def helper(self): email_notification_fields.append("updates_email_notification") fields = [ - Div( - Field("username", wrapper_class="col-md-4"), - Field("email", wrapper_class="col-md-4"), - dataspace_field, - css_class="row", - ), - Div( - Field("first_name", wrapper_class="col-md-6"), - Field("last_name", wrapper_class="col-md-6"), - css_class="row", - ), + Group("username", "email", dataspace_field), + Group("first_name", "last_name"), + Group("timezone", None), HTML("
"), Group(*email_notification_fields), homepage_layout_field, diff --git a/dje/middleware.py b/dje/middleware.py index 932f7d4e..c0d9e376 100644 --- a/dje/middleware.py +++ b/dje/middleware.py @@ -8,6 +8,7 @@ import json import logging +import zoneinfo from datetime import datetime from django.http import Http404 @@ -88,3 +89,40 @@ def __call__(self, request): raise Http404 return self.get_response(request) + + +def validate_timezone(tz): + """Return a valid timezone or None if invalid.""" + try: + return zoneinfo.ZoneInfo(tz).key + except (zoneinfo.ZoneInfoNotFoundError, TypeError, ValueError): + return + + +class TimezoneMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + tz = self.get_timezone_from_request(request) + + if tz: + timezone.activate(zoneinfo.ZoneInfo(tz)) + else: + timezone.deactivate() + + return self.get_response(request) + + @staticmethod + def get_timezone_from_request(request): + """ + Determine the appropriate timezone for the request, prioritizing user settings + but falling back to the browser's timezone if necessary. + """ + # 1. Try user profile timezone (if authenticated) + if request.user.is_authenticated: + if tz := validate_timezone(request.user.timezone): + return tz + + # 2. Fallback to browser timezone from cookie + return validate_timezone(request.COOKIES.get("client_timezone")) diff --git a/dje/migrations/0007_dejacodeuser_timezone.py b/dje/migrations/0007_dejacodeuser_timezone.py new file mode 100644 index 00000000..b166ef35 --- /dev/null +++ b/dje/migrations/0007_dejacodeuser_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2025-02-13 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0006_dejacodeuser_vulnerability_impact_notifications'), + ] + + operations = [ + migrations.AddField( + model_name='dejacodeuser', + name='timezone', + field=models.CharField(blank=True, help_text="Select your preferred time zone. This will affect how times are displayed across the app. If you don't set a timezone, UTC will be used by default.", max_length=50, null=True, verbose_name='time zone'), + ), + ] diff --git a/dje/models.py b/dje/models.py index 684bbb2b..b4b4e514 100644 --- a/dje/models.py +++ b/dje/models.py @@ -1657,6 +1657,17 @@ class DejacodeUser(AbstractUser): ), ) + timezone = models.CharField( + verbose_name=_("time zone"), + max_length=50, + null=True, + blank=True, + help_text=_( + "Select your preferred time zone. This will affect how times are displayed " + "across the app. If you don't set a timezone, UTC will be used by default." + ), + ) + objects = DejacodeUserManager() class Meta: diff --git a/dje/templates/bootstrap_base_js.html b/dje/templates/bootstrap_base_js.html index d037b84c..491ed5fb 100644 --- a/dje/templates/bootstrap_base_js.html +++ b/dje/templates/bootstrap_base_js.html @@ -2,4 +2,13 @@ \ No newline at end of file diff --git a/dje/templates/includes/field_history_changes.html b/dje/templates/includes/field_history_changes.html index 8526f7ea..1b64187d 100644 --- a/dje/templates/includes/field_history_changes.html +++ b/dje/templates/includes/field_history_changes.html @@ -14,7 +14,7 @@ {% with change_message=history_entry.get_change_message %}
  • {{ change_message }} - + {{ history_entry.action_time|naturaltime }} by {{ history_entry.user }}
  • diff --git a/dje/tests/test_api.py b/dje/tests/test_api.py index 2d0ba725..18af4441 100644 --- a/dje/tests/test_api.py +++ b/dje/tests/test_api.py @@ -497,8 +497,8 @@ def test_api_external_reference_detail_endpoint(self): self.assertEqual(self.ext_ref1.content_type.model, response.data["content_type"]) self.assertEqual(self.ext_ref1.external_url, response.data["external_url"]) self.assertEqual(self.ext_ref1.external_id, response.data["external_id"]) - self.assertEqual(32, len(response.data["created_date"])) - self.assertEqual(32, len(response.data["last_modified_date"])) + self.assertTrue(response.data["created_date"]) + self.assertTrue(response.data["last_modified_date"]) def test_api_external_reference_endpoint_create(self): self.client.login(username="super_user", password="secret") diff --git a/dje/tests/test_middleware.py b/dje/tests/test_middleware.py new file mode 100644 index 00000000..7910d3d3 --- /dev/null +++ b/dje/tests/test_middleware.py @@ -0,0 +1,107 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from unittest.mock import MagicMock + +from django.test import RequestFactory +from django.test import TestCase +from django.utils import timezone + +from dje.middleware import TimezoneMiddleware +from dje.middleware import validate_timezone +from dje.models import Dataspace +from dje.tests import create_superuser + + +class DJEMiddlewaresTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("nexb_user", self.dataspace) + + def test_dje_middleware_prohibit_in_query_string_middleware(self): + response = self.client.get("/?a=%00", follow=True) + self.assertEqual(404, response.status_code) + + self.client.login(username=self.super_user.username, password="secret") + response = self.client.get("/?a=%00") + self.assertEqual(404, response.status_code) + + +class TimezoneMiddlewareTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.middleware = TimezoneMiddleware(lambda req: req) + + def test_validate_timezone(self): + self.assertEqual(validate_timezone("America/New_York"), "America/New_York") + self.assertIsNone(validate_timezone("Invalid/Timezone")) + self.assertIsNone(validate_timezone(None)) + + def test_user_authenticated_with_valid_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = True + request.user.timezone = "Europe/London" + + tz = self.middleware.get_timezone_from_request(request) + self.assertEqual(tz, "Europe/London") + + def test_user_authenticated_with_invalid_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = True + request.user.timezone = "Invalid/Timezone" + + tz = self.middleware.get_timezone_from_request(request) + self.assertIsNone(tz) + + def test_user_not_authenticated_with_valid_cookie_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = False + request.COOKIES["client_timezone"] = "Asia/Tokyo" + + tz = self.middleware.get_timezone_from_request(request) + self.assertEqual(tz, "Asia/Tokyo") + + def test_user_not_authenticated_with_invalid_cookie_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = False + request.COOKIES["client_timezone"] = "Invalid/Timezone" + + tz = self.middleware.get_timezone_from_request(request) + self.assertIsNone(tz) + + def test_middleware_activates_valid_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = True + request.user.timezone = "Europe/London" + + self.middleware(request) + self.assertEqual(timezone.get_current_timezone_name(), "Europe/London") + + def test_middleware_deactivates_invalid_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = True + request.user.timezone = "Invalid/Timezone" + + self.middleware(request) + self.assertEqual(timezone.get_current_timezone_name(), "UTC") + + def test_middleware_uses_cookie_if_no_user_timezone(self): + request = self.factory.get("/") + request.user = MagicMock() + request.user.is_authenticated = True + request.user.timezone = None + request.COOKIES["client_timezone"] = "Asia/Tokyo" + + self.middleware(request) + self.assertEqual(timezone.get_current_timezone_name(), "Asia/Tokyo") diff --git a/dje/tests/tests.py b/dje/tests/test_templatetags.py similarity index 64% rename from dje/tests/tests.py rename to dje/tests/test_templatetags.py index 6247cde9..0e83c7b0 100644 --- a/dje/tests/tests.py +++ b/dje/tests/test_templatetags.py @@ -11,45 +11,12 @@ from django.test import TestCase from django.utils import timezone -from dje.models import Dataspace from dje.templatetags.dje_tags import naturaltime_short from dje.templatetags.dje_tags import urlize_target_blank -from dje.tests import create_superuser -from organization.models import Owner - - -class URNResolverTestCase(TestCase): - def setUp(self): - self.dataspace1 = Dataspace.objects.create(name="Dataspace") - self.owner1 = Owner.objects.create( - name="CCAD - Combined Conditional Access Development, LLC.", dataspace=self.dataspace1 - ) - - def test_organization_get_urn(self): - expected = "urn:dje:owner:CCAD+-+Combined+Conditional+Access+Development%2C+LLC." - self.assertEqual(expected, self.owner1.urn) - - def test_org_urns_with_colons_in_name_are_valid_urns(self): - org = Owner.objects.create(name="some:org", dataspace=self.dataspace1) - self.assertEqual("urn:dje:owner:some%3Aorg", org.urn) - - -class MiddlewareTestCase(TestCase): - def setUp(self): - self.dataspace = Dataspace.objects.create(name="nexB") - self.super_user = create_superuser("nexb_user", self.dataspace) - - def test_prohibit_in_query_string_middleware(self): - response = self.client.get("/?a=%00", follow=True) - self.assertEqual(404, response.status_code) - - self.client.login(username=self.super_user.username, password="secret") - response = self.client.get("/?a=%00") - self.assertEqual(404, response.status_code) class TemplateTagsTestCase(TestCase): - def test_dje_templatetag_urlize_target_blank_template_tag(self): + def test_dje_templatetags_urlize_target_blank_template_tag(self): inputs = [ ( "domain.com", @@ -75,7 +42,7 @@ def test_dje_templatetag_urlize_target_blank_template_tag(self): for url, expected in inputs: self.assertEqual(expected, urlize_target_blank(url)) - def test_dje_templatetag_naturaltime_short(self): + def test_dje_templatetags_naturaltime_short(self): now = timezone.now() test_list = [ diff --git a/dje/tests/test_utils.py b/dje/tests/test_utils.py index e3f6f982..96b4a2ce 100644 --- a/dje/tests/test_utils.py +++ b/dje/tests/test_utils.py @@ -7,6 +7,7 @@ # import zipfile +import zoneinfo from unittest import mock from django.apps import apps @@ -18,6 +19,7 @@ from django.test.client import RequestFactory from django.urls import ResolverMatch from django.urls import resolve +from django.utils import timezone from dejacode_toolkit.utils import md5 from dejacode_toolkit.utils import sha1 @@ -34,6 +36,7 @@ from dje.utils import group_by_name_version from dje.utils import is_available from dje.utils import is_purl_str +from dje.utils import localized_datetime from dje.utils import merge_relations from dje.utils import normalize_newlines_as_CR_plus_LF from dje.utils import remove_field_from_query_dict @@ -498,3 +501,18 @@ def test_utils_is_purl_str(self): self.assertTrue(is_purl_str("pkg:npm/is-npm@1.0.0")) self.assertTrue(is_purl_str("pkg:npm/is-npm@1.0.0", validate=True)) + + def test_utils_localized_datetime(self): + self.assertIsNone(localized_datetime(None)) + + timezone.deactivate() + dt = "2025-01-13T19:11:08.216188" + self.assertEqual("Jan 13, 2025, 7:11 PM UTC", localized_datetime(dt)) + dt = "2025-01-13T19:11:08.216188+01:00" + self.assertEqual("Jan 13, 2025, 6:11 PM UTC", localized_datetime(dt)) + + timezone.activate(zoneinfo.ZoneInfo("America/Los_Angeles")) + dt = "2025-01-13T19:11:08.216188" + self.assertEqual("Jan 13, 2025, 11:11 AM PST", localized_datetime(dt)) + dt = "2025-01-13T19:11:08.216188+01:00" + self.assertEqual("Jan 13, 2025, 10:11 AM PST", localized_datetime(dt)) diff --git a/dje/tests/testfiles/test_dataset_user_only.json b/dje/tests/testfiles/test_dataset_user_only.json index 02dbf6b1..22e3817e 100644 --- a/dje/tests/testfiles/test_dataset_user_only.json +++ b/dje/tests/testfiles/test_dataset_user_only.json @@ -50,6 +50,7 @@ "vulnerability_impact_notification": false, "company": "", "last_api_access": null, + "timezone": null, "groups": [], "user_permissions": [] } diff --git a/dje/utils.py b/dje/utils.py index 98bfe3f8..feb14406 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -25,6 +25,10 @@ from django.urls import Resolver404 from django.urls import resolve from django.urls import reverse +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from django.utils.formats import date_format +from django.utils.formats import get_format from django.utils.html import format_html from django.utils.html import mark_safe from django.utils.http import urlencode @@ -662,3 +666,24 @@ def humanize_time(seconds): message += f" ({seconds / 60:.1f} minutes)" return message + + +def localized_datetime(datetime): + """ + Format a given datetime string into the application's default display format, + ensuring it is timezone-aware and localized to match Django's template behavior. + """ + if not datetime: + return + + dt = parse_datetime(datetime) + + # Ensure timezone awareness (use default if naive) + if dt and not timezone.is_aware(dt): + dt = dt.replace(tzinfo=timezone.get_default_timezone()) + + # Convert to local time to match template behavior + dt = timezone.localtime(dt) + + default_format = get_format("DATETIME_FORMAT") + return date_format(dt, default_format) diff --git a/organization/tests/test_api.py b/organization/tests/test_api.py index 45fe0b30..f92a1ee4 100644 --- a/organization/tests/test_api.py +++ b/organization/tests/test_api.py @@ -152,19 +152,21 @@ def test_api_owner_list_endpoint_ordering(self): self.assertEqual(self.owner2.name, response.data["results"][1].get("name")) def test_api_owner_detail_endpoint(self): + from component_catalog.tests import make_component from license_library.models import License - self.license = License.objects.create( + license = License.objects.create( key="key", owner=self.owner1, name="name", short_name="short_name", dataspace=self.dataspace, ) - from component_catalog.models import Component - self.component = Component.objects.create( - name="component", owner=self.owner1, dataspace=self.dataspace + component = make_component( + name="component", + owner=self.owner1, + dataspace=self.dataspace, ) self.client.login(username="super_user", password="secret") @@ -184,13 +186,13 @@ def test_api_owner_detail_endpoint(self): self.assertEqual("", response.data["homepage_url"]) self.assertEqual("", response.data["notes"]) self.assertEqual("nexB", response.data["dataspace"]) - self.assertEqual(32, len(response.data["created_date"])) - self.assertEqual(32, len(response.data["last_modified_date"])) + self.assertTrue(response.data["created_date"]) + self.assertTrue(response.data["last_modified_date"]) self.assertIn( - reverse("api_v2:license-detail", args=[self.license.uuid]), response.data["licenses"][0] + reverse("api_v2:license-detail", args=[license.uuid]), response.data["licenses"][0] ) self.assertIn( - reverse("api_v2:component-detail", args=[self.component.uuid]), + reverse("api_v2:component-detail", args=[component.uuid]), response.data["components"][0], ) self.assertEqual(self.owner1.urn, response.data["urn"]) diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_imports.html b/product_portfolio/templates/product_portfolio/tabs/tab_imports.html index 75ddffc6..58b23982 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_imports.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_imports.html @@ -34,7 +34,7 @@
    {% endif %}
    - + {{ scancode_project.created_date|naturaltime_short }} by {{ scancode_project.created_by }} diff --git a/workflow/templates/workflow/comment_created_email.txt b/workflow/templates/workflow/comment_created_email.txt index f70d9823..ca9b5071 100644 --- a/workflow/templates/workflow/comment_created_email.txt +++ b/workflow/templates/workflow/comment_created_email.txt @@ -1,4 +1,4 @@ -{% autoescape off %}Request {{ req }} {% if action == 'closed' %}closed{% else %}has a new comment{% endif %} in the {{ comment.dataspace }} dataspace on {{ comment.created_date|date:'N d, Y, h:ia' }} +{% autoescape off %}Request {{ req }} {% if action == 'closed' %}closed{% else %}has a new comment{% endif %} in the {{ comment.dataspace }} dataspace on {{ comment.created_date }} Comment by {{ comment.user }}: '{{ comment.text }}' diff --git a/workflow/templates/workflow/includes/comment_event.html b/workflow/templates/workflow/includes/comment_event.html index 2a5ced3a..1c3e1358 100644 --- a/workflow/templates/workflow/includes/comment_event.html +++ b/workflow/templates/workflow/includes/comment_event.html @@ -5,7 +5,7 @@ {{ event.text|linebreaksbr }}
    {{ action }} by {{ event.user }} - {{ event.created_date|naturaltime }} + {{ event.created_date|naturaltime }}
    \ No newline at end of file diff --git a/workflow/templates/workflow/includes/comments_section.html b/workflow/templates/workflow/includes/comments_section.html index 8b60bdb6..c1d36ea4 100644 --- a/workflow/templates/workflow/includes/comments_section.html +++ b/workflow/templates/workflow/includes/comments_section.html @@ -14,7 +14,11 @@ {% endif %}
    - {{ event.user.username }} commented {{ event.created_date|naturaltime }} + {{ event.user.username }} + commented + + {{ event.created_date|naturaltime }} +
    {{ event.as_html }} diff --git a/workflow/templates/workflow/includes/request_home_dashboard.html b/workflow/templates/workflow/includes/request_home_dashboard.html index 8d374148..8592ded9 100644 --- a/workflow/templates/workflow/includes/request_home_dashboard.html +++ b/workflow/templates/workflow/includes/request_home_dashboard.html @@ -16,11 +16,11 @@

    {% if req.last_modified_by %}
    - Last edited {{ req.last_modified_date|naturaltime }} by {{ req.last_modified_by.username }} + Last edited {{ req.last_modified_date|naturaltime }} by {{ req.last_modified_by.username }}
    {% else %}
    - Created {{ req.created_date|naturaltime }} by {{ req.requester.username }} + Created {{ req.created_date|naturaltime }} by {{ req.requester.username }}
    {% endif %} {% if req.assignee and filter_name != 'assignee' %} diff --git a/workflow/templates/workflow/includes/request_list_table.html b/workflow/templates/workflow/includes/request_list_table.html index 81b34796..d675007b 100644 --- a/workflow/templates/workflow/includes/request_list_table.html +++ b/workflow/templates/workflow/includes/request_list_table.html @@ -64,14 +64,14 @@ {% endif %} - Created {{ req.created_date|naturaltime }} by + Created {{ req.created_date|naturaltime }} by {% if filter.form.requester %} {{ req.requester.username }} {% else %} {{ req.requester.username }} {% endif %} {% if req.last_modified_by %} - • Last edited {{ req.last_modified_date|naturaltime }} by {{ req.last_modified_by.username }} + • Last edited {{ req.last_modified_date|naturaltime }} by {{ req.last_modified_by.username }} {% endif %}
    {% if not exclude_product_context and req.product_context %} diff --git a/workflow/templates/workflow/request_created_email.txt b/workflow/templates/workflow/request_created_email.txt index 4c8cc6ca..2ed42c9d 100644 --- a/workflow/templates/workflow/request_created_email.txt +++ b/workflow/templates/workflow/request_created_email.txt @@ -1,4 +1,4 @@ -{% autoescape off %}{{ req.requester }} has submitted a new {% if req.is_private %}private{% else %}public{% endif %} request in the {{ req.dataspace }} dataspace on {{ req.created_date|date:'N d, Y, h:ia' }} +{% autoescape off %}{{ req.requester }} has submitted a new {% if req.is_private %}private{% else %}public{% endif %} request in the {{ req.dataspace }} dataspace on {{ req.created_date }} ================================================================================ Ref. {{ req }} {{ req.title }} diff --git a/workflow/templates/workflow/request_details.html b/workflow/templates/workflow/request_details.html index 1ecd9620..540eb7e4 100644 --- a/workflow/templates/workflow/request_details.html +++ b/workflow/templates/workflow/request_details.html @@ -74,12 +74,12 @@

    {% endif %}

    - Created {{ request_instance.created_date|naturaltime }} by {{ request_instance.requester.username }} + Created {{ request_instance.created_date|naturaltime }} by {{ request_instance.requester.username }} {% if request_instance.assignee %} • Assigned to {{ request_instance.assignee.username }} {% endif %} {% if request_instance.last_modified_by %} - • Last edited {{ request_instance.last_modified_date|naturaltime }} by {{ request_instance.last_modified_by.username }} + • Last edited {{ request_instance.last_modified_date|naturaltime }} by {{ request_instance.last_modified_by.username }} {% endif %}

    diff --git a/workflow/templates/workflow/request_updated_email.txt b/workflow/templates/workflow/request_updated_email.txt index c0b0282d..cb629cc4 100644 --- a/workflow/templates/workflow/request_updated_email.txt +++ b/workflow/templates/workflow/request_updated_email.txt @@ -1,4 +1,4 @@ -{% autoescape off %}The request {{ req }} has been updated in the {{ req.dataspace }} dataspace on {{ req.last_modified_date|date:'N d, Y, h:ia' }} +{% autoescape off %}The request {{ req }} has been updated in the {{ req.dataspace }} dataspace on {{ req.last_modified_date }} ================================================================================ Ref. {{ req }} {{ req.title }}