Skip to content
16 changes: 16 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
</strong>
</div>
<div class="text-muted-darker">
Created <span title="{{ scan.created_date|date:'N j, Y, f A T' }}">{{ scan.created_date|naturaltime }}</span>
Created <span title="{{ scan.created_date }}">{{ scan.created_date|naturaltime }}</span>
</div>
</div>
<div class="col-auto" style="width: 200px;">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@
<a href="{% inject_preserved_filters object.get_absolute_url %}">{{ object.name }}</a>
</strong>
{% if filter.show_created_date %}
<div class="smaller text-muted-darker" title="{{ object.created_date|date:'N j, Y, f A' }}">Created {{ object.created_date|naturaltime_short }}</div>
<div class="smaller text-muted-darker" title="{{ object.created_date }}">Created {{ object.created_date|naturaltime_short }}</div>
{% endif %}
{% if filter.show_last_modified_date %}
<div class="smaller text-muted-darker" title="{{ object.last_modified_date|date:'N j, Y, f A' }}">Modified {{ object.last_modified_date|naturaltime_short }}</div>
<div class="smaller text-muted-darker" title="{{ object.last_modified_date }}">Modified {{ object.last_modified_date|naturaltime_short }}</div>
{% endif %}
</td>
<td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@
</a>
</strong>
{% if filter.show_created_date %}
<div class="smaller text-muted-darker" title="{{ object.created_date|date:'N j, Y, f A' }}">Created {{ object.created_date|naturaltime_short }}</div>
<div class="smaller text-muted-darker" title="{{ object.created_date }}">Created {{ object.created_date|naturaltime_short }}</div>
{% endif %}
{% if filter.show_last_modified_date %}
<div class="smaller text-muted-darker" title="{{ object.last_modified_date|date:'N j, Y, f A' }}">Modified {{ object.last_modified_date|naturaltime_short }}</div>
<div class="smaller text-muted-darker" title="{{ object.last_modified_date }}">Modified {{ object.last_modified_date|naturaltime_short }}</div>
{% endif %}
</td>
{% if dataspace.show_usage_policy_in_user_views %}
Expand Down
6 changes: 4 additions & 2 deletions component_catalog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2079,14 +2079,16 @@ def test_package_details_view_scan_tab_scan_in_progress(
<button class="btn-clipboard" data-bs-toggle="tooltip" title="Copy to clipboard">
<i class="fas fa-clipboard"></i></button>
<pre class="pre-bg-body-tertiary mb-1 field-created-date">
June 21, 2018, 12:32 PM UTC
Jun 21, 2018, 12:32 PM UTC
</pre>
</dd>
<dt class="col-sm-2 text-end pt-2 pe-0">Start date</dt>
<dd class="col-sm-10 clipboard">
<button class="btn-clipboard" data-bs-toggle="tooltip" title="Copy to clipboard">
<i class="fas fa-clipboard"></i></button>
<pre class="pre-bg-body-tertiary mb-1 field-start-date">June 21, 2018, 12:32 PM UTC</pre>
<pre class="pre-bg-body-tertiary mb-1 field-start-date">
Jun 21, 2018, 12:32 PM UTC
</pre>
</dd>
<dt class="col-sm-2 text-end pt-2 pe-0">End date</dt>
<dd class="col-sm-10 clipboard">
Expand Down
18 changes: 4 additions & 14 deletions component_catalog/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand All @@ -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")),
]
)
Expand Down
4 changes: 2 additions & 2 deletions dejacode/formats/cu/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions dejacode/formats/en/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion dejacode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")

Expand Down Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions dje/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
25 changes: 13 additions & 12 deletions dje/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@
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
from dje.models import is_content_type_related
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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -295,7 +304,7 @@ def helper(self):
# of the form, for security purposes.
dataspace_field = HTML(
f"""
<div id="div_id_dataspace" class="col-md-4">
<div id="div_id_dataspace">
<label for="id_dataspace" class="form-label">Dataspace</label>
<input type="text" value="{self.instance.dataspace}" class="form-control" disabled=""
id="id_dataspace">
Expand All @@ -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("<hr>"),
Group(*email_notification_fields),
homepage_layout_field,
Expand Down
38 changes: 38 additions & 0 deletions dje/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import json
import logging
import zoneinfo
from datetime import datetime

from django.http import Http404
Expand Down Expand Up @@ -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"))
18 changes: 18 additions & 0 deletions dje/migrations/0007_dejacodeuser_timezone.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Loading