From bc32edaf271aa8ce2e88bbecaace001b2f9c74b5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 09:00:59 -1000 Subject: [PATCH 01/10] Update the default DATETIME_FORMAT to a more readable string #240 Signed-off-by: tdruez --- dejacode/formats/cu/formats.py | 4 ++-- dejacode/formats/en/formats.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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" From 1d35c07b8b00af9b69654c7b98b9f444247e380b Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 09:55:44 -1000 Subject: [PATCH 02/10] Add a timezone field on the User model and form #240 Signed-off-by: tdruez --- dejacode/settings.py | 3 ++- dje/forms.py | 28 +++++++++++--------- dje/middleware.py | 16 +++++++++++ dje/migrations/0007_dejacodeuser_timezone.py | 18 +++++++++++++ dje/models.py | 11 ++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 dje/migrations/0007_dejacodeuser_timezone.py 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/forms.py b/dje/forms.py index 460bb957..714a0459 100644 --- a/dje/forms.py +++ b/dje/forms.py @@ -7,6 +7,7 @@ # import uuid +from zoneinfo import available_timezones from django import forms from django.conf import settings @@ -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,12 @@ def identifier_fields(self): class AccountProfileForm(ScopeAndProtectRelationships, forms.ModelForm): username = forms.CharField(disabled=True, required=False) email = forms.CharField(disabled=True, required=False) + timezone = forms.ChoiceField( + label=_("Time zone"), + choices=[("", "Select Time zone")] + [(tz, tz) for tz in sorted(available_timezones())], + required=False, + widget=forms.Select(attrs={"aria-label": "Select Time Zone"}), + ) class Meta: model = UserModel @@ -268,12 +276,16 @@ 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) + model = self._meta.model + self.fields["timezone"].help_text = get_help_text(model, "timezone") + def save(self, commit=True): instance = super().save(commit) changed_fields = ", ".join(self.changed_data) @@ -295,7 +307,7 @@ def helper(self): # of the form, for security purposes. dataspace_field = HTML( f""" -
+
@@ -312,17 +324,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..7cfdb191 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,18 @@ def __call__(self, request): raise Http404 return self.get_response(request) + + +class TimezoneMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and request.user.timezone: + try: + timezone.activate(zoneinfo.ZoneInfo(request.user.timezone)) + except zoneinfo.ZoneInfoNotFoundError: + timezone.deactivate() + else: + timezone.deactivate() + return self.get_response(request) 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: From 693e463752e7f8a251977f2fdc21a8f4c8564539 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 10:32:11 -1000 Subject: [PATCH 03/10] Add a TimeZoneChoiceField to render the options #240 Signed-off-by: tdruez --- dje/fields.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ dje/forms.py | 5 ++--- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/dje/fields.py b/dje/fields.py index bc88c2c1..4590825e 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,55 @@ class JSONListField(models.JSONField): def __init__(self, *args, **kwargs): kwargs["default"] = list super().__init__(*args, **kwargs) + + +class TimeZoneChoiceField(forms.ChoiceField): + def __init__(self, *args, **kwargs): + choices = [("", "Select Time zone")] + timezone_offsets = [] + + # Collect timezones with numeric offsets + for tz in zoneinfo.available_timezones(): + # Skip POSIX-style 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) + city = tz.split("/")[-1].replace("_", " ") + formatted_name = f"{formatted_offset} {city}" + timezone_offsets.append((tz, offset_hours, formatted_name)) + + # Correctly sort by numeric offset value + timezone_offsets.sort(key=lambda x: x[1]) # Sort by UTC offset number + + # Populate choices in correct order + for tz, _offset_hours, formatted_name in timezone_offsets: + choices.append((tz, formatted_name)) + + # Assign the choices to the field + kwargs["choices"] = choices + + # Call the parent class initialization + super().__init__(*args, **kwargs) + + # You can also handle help_text or other customizations here + if "help_text" not in kwargs: + kwargs["help_text"] = "Select your preferred time zone." + + @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 numeric value for sorting & formatted string + return offset_hours, formatted_offset diff --git a/dje/forms.py b/dje/forms.py index 714a0459..c83b8615 100644 --- a/dje/forms.py +++ b/dje/forms.py @@ -7,7 +7,6 @@ # import uuid -from zoneinfo import available_timezones from django import forms from django.conf import settings @@ -37,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 @@ -258,9 +258,8 @@ def identifier_fields(self): class AccountProfileForm(ScopeAndProtectRelationships, forms.ModelForm): username = forms.CharField(disabled=True, required=False) email = forms.CharField(disabled=True, required=False) - timezone = forms.ChoiceField( + timezone = TimeZoneChoiceField( label=_("Time zone"), - choices=[("", "Select Time zone")] + [(tz, tz) for tz in sorted(available_timezones())], required=False, widget=forms.Select(attrs={"aria-label": "Select Time Zone"}), ) From 1b29d7778915c7d808eee77d2f324d87d6a219a5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 11:27:17 -1000 Subject: [PATCH 04/10] Use consistent rendering of data across the app #240 Signed-off-by: tdruez --- .../component_catalog/includes/scan_list_table.html | 2 +- .../component_catalog/tables/component_list_table.html | 4 ++-- .../component_catalog/tables/package_list_table.html | 4 ++-- dje/templates/includes/field_history_changes.html | 2 +- .../templates/product_portfolio/tabs/tab_imports.html | 2 +- workflow/templates/workflow/comment_created_email.txt | 2 +- workflow/templates/workflow/includes/comment_event.html | 2 +- workflow/templates/workflow/includes/comments_section.html | 6 +++++- .../templates/workflow/includes/request_home_dashboard.html | 4 ++-- .../templates/workflow/includes/request_list_table.html | 4 ++-- workflow/templates/workflow/request_created_email.txt | 2 +- workflow/templates/workflow/request_details.html | 4 ++-- workflow/templates/workflow/request_updated_email.txt | 2 +- 13 files changed, 22 insertions(+), 18 deletions(-) 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/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/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 }} From b96445936735f682fc8af144e9f02796d8eda063 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 12:02:08 -1000 Subject: [PATCH 05/10] Use consistent rendering of date across the app #240 Signed-off-by: tdruez --- component_catalog/views.py | 18 ++++-------------- dje/utils.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) 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/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) From 76520ae40b46233bfd2d659fcf2f19336b8358bc Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 12:24:06 -1000 Subject: [PATCH 06/10] Fix failing tests #240 Signed-off-by: tdruez --- component_catalog/tests/test_views.py | 6 ++++-- dje/forms.py | 4 +--- dje/middleware.py | 12 ++++++++---- dje/tests/test_api.py | 4 ++-- .../testfiles/test_dataset_user_only.json | 1 + organization/tests/test_api.py | 18 ++++++++++-------- 6 files changed, 26 insertions(+), 19 deletions(-) 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/dje/forms.py b/dje/forms.py index c83b8615..417d8a5e 100644 --- a/dje/forms.py +++ b/dje/forms.py @@ -281,9 +281,7 @@ class Meta: def __init__(self, *args, **kwargs): self.user = kwargs.get("instance") super().__init__(*args, **kwargs) - - model = self._meta.model - self.fields["timezone"].help_text = get_help_text(model, "timezone") + self.fields["timezone"].help_text = get_help_text(self._meta.model, "timezone") def save(self, commit=True): instance = super().save(commit) diff --git a/dje/middleware.py b/dje/middleware.py index 7cfdb191..f8a04c12 100644 --- a/dje/middleware.py +++ b/dje/middleware.py @@ -97,10 +97,14 @@ def __init__(self, get_response): def __call__(self, request): if request.user.is_authenticated and request.user.timezone: - try: - timezone.activate(zoneinfo.ZoneInfo(request.user.timezone)) - except zoneinfo.ZoneInfoNotFoundError: - timezone.deactivate() + self.activate_user_profile_timezone(user=request.user) else: timezone.deactivate() return self.get_response(request) + + @staticmethod + def activate_user_profile_timezone(user): + try: + timezone.activate(zoneinfo.ZoneInfo(user.timezone)) + except zoneinfo.ZoneInfoNotFoundError: + timezone.deactivate() 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/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/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"]) From 0cfe60b5e98cac70894674badcd354c6924d85a9 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 12:46:38 -1000 Subject: [PATCH 07/10] Detect the user timezone from the browser #240 Signed-off-by: tdruez --- dje/middleware.py | 32 ++++++++++++++++++++++------ dje/templates/bootstrap_base_js.html | 13 +++++++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/dje/middleware.py b/dje/middleware.py index f8a04c12..c0d9e376 100644 --- a/dje/middleware.py +++ b/dje/middleware.py @@ -91,20 +91,38 @@ def __call__(self, request): 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): - if request.user.is_authenticated and request.user.timezone: - self.activate_user_profile_timezone(user=request.user) + tz = self.get_timezone_from_request(request) + + if tz: + timezone.activate(zoneinfo.ZoneInfo(tz)) else: timezone.deactivate() + return self.get_response(request) @staticmethod - def activate_user_profile_timezone(user): - try: - timezone.activate(zoneinfo.ZoneInfo(user.timezone)) - except zoneinfo.ZoneInfoNotFoundError: - timezone.deactivate() + 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/templates/bootstrap_base_js.html b/dje/templates/bootstrap_base_js.html index d037b84c..bcfa558a 100644 --- a/dje/templates/bootstrap_base_js.html +++ b/dje/templates/bootstrap_base_js.html @@ -1,5 +1,14 @@ {{ client_data|json_script:"client_data" }} \ No newline at end of file From 1c63edbc73443d638250ec22742e670227913392 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 14:24:18 -1000 Subject: [PATCH 08/10] Refine the option value of timezone #240 Signed-off-by: tdruez --- dje/fields.py | 71 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/dje/fields.py b/dje/fields.py index 4590825e..ea11a183 100644 --- a/dje/fields.py +++ b/dje/fields.py @@ -208,39 +208,47 @@ def __init__(self, *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_offsets = [] + choices = [("", "Select Time Zone")] + timezone_groups = {} - # Collect timezones with numeric offsets - for tz in zoneinfo.available_timezones(): - # Skip POSIX-style timezones + # 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) - city = tz.split("/")[-1].replace("_", " ") - formatted_name = f"{formatted_offset} {city}" - timezone_offsets.append((tz, offset_hours, formatted_name)) + standard_name, city = self.get_timezone_parts(tz) - # Correctly sort by numeric offset value - timezone_offsets.sort(key=lambda x: x[1]) # Sort by UTC offset number + formatted_name = f"{formatted_offset}" + if standard_name: + formatted_name += f" {standard_name} - {city}" + else: + formatted_name += f" {city}" - # Populate choices in correct order - for tz, _offset_hours, formatted_name in timezone_offsets: - choices.append((tz, formatted_name)) + if offset_hours not in timezone_groups: + timezone_groups[offset_hours] = [] + timezone_groups[offset_hours].append((tz, formatted_name)) - # Assign the choices to the field - kwargs["choices"] = choices + # 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)) - # Call the parent class initialization + kwargs["choices"] = choices super().__init__(*args, **kwargs) - # You can also handle help_text or other customizations here - if "help_text" not in kwargs: - kwargs["help_text"] = "Select your preferred time zone." - @staticmethod def get_timezone_offset(tz): """Return the numeric offset for sorting and formatted offset for display.""" @@ -255,5 +263,26 @@ def get_timezone_offset(tz): minutes = int((abs(offset_hours) - hours) * 60) formatted_offset = f"(GMT{sign}{hours:02}:{minutes:02})" - # Return numeric value for sorting & formatted string 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 From 5d928cd336e0c3da086705e90b4749e751f87633 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 14:43:38 -1000 Subject: [PATCH 09/10] Add changelog entry #240 Signed-off-by: tdruez --- CHANGELOG.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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. From c0344ae49467647a95df780fda5d9870e9224737 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 13 Feb 2025 15:09:42 -1000 Subject: [PATCH 10/10] Add unit tests #240 Signed-off-by: tdruez --- dje/templates/bootstrap_base_js.html | 6 +- dje/tests/test_middleware.py | 107 +++++++++++++++++++ dje/tests/{tests.py => test_templatetags.py} | 37 +------ dje/tests/test_utils.py | 18 ++++ 4 files changed, 130 insertions(+), 38 deletions(-) create mode 100644 dje/tests/test_middleware.py rename dje/tests/{tests.py => test_templatetags.py} (64%) diff --git a/dje/templates/bootstrap_base_js.html b/dje/templates/bootstrap_base_js.html index bcfa558a..491ed5fb 100644 --- a/dje/templates/bootstrap_base_js.html +++ b/dje/templates/bootstrap_base_js.html @@ -1,9 +1,9 @@ {{ client_data|json_script:"client_data" }}