Closes #15618: Always use ISO 8601 date & time formatting (#15737)

* Introduce the isodate(), isotime(), and isodatetime() template filters

* Display the relative time on mouse hover

* Render journal entry times in ISO 8601 format

* Use ISO 8601 format when displaying dates & times in a table

* Standardize the use of DateTimeColumn across all tables
This commit is contained in:
Jeremy Stretch 2024-04-17 11:46:47 -04:00 committed by GitHub
parent f0aca5bac1
commit 77a4300888
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 109 additions and 67 deletions

View File

@ -30,10 +30,12 @@ class UserTokenTable(NetBoxTable):
write_enabled = columns.BooleanColumn( write_enabled = columns.BooleanColumn(
verbose_name=_('Write Enabled') verbose_name=_('Write Enabled')
) )
created = columns.DateColumn( created = columns.DateTimeColumn(
timespec='minutes',
verbose_name=_('Created'), verbose_name=_('Created'),
) )
expires = columns.DateColumn( expires = columns.DateTimeColumn(
timespec='minutes',
verbose_name=_('Expires'), verbose_name=_('Expires'),
) )
last_used = columns.DateTimeColumn( last_used = columns.DateTimeColumn(

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import A from django_tables2.utils import A
from core.tables.columns import RQJobStatusColumn from core.tables.columns import RQJobStatusColumn
from netbox.tables import BaseTable from netbox.tables import BaseTable, columns
class BackgroundQueueTable(BaseTable): class BackgroundQueueTable(BaseTable):
@ -75,13 +75,13 @@ class BackgroundTaskTable(BaseTable):
linkify=("core:background_task", [A("id")]), linkify=("core:background_task", [A("id")]),
verbose_name=_("ID") verbose_name=_("ID")
) )
created_at = tables.DateTimeColumn( created_at = columns.DateTimeColumn(
verbose_name=_("Created") verbose_name=_("Created")
) )
enqueued_at = tables.DateTimeColumn( enqueued_at = columns.DateTimeColumn(
verbose_name=_("Enqueued") verbose_name=_("Enqueued")
) )
ended_at = tables.DateTimeColumn( ended_at = columns.DateTimeColumn(
verbose_name=_("Ended") verbose_name=_("Ended")
) )
status = RQJobStatusColumn( status = RQJobStatusColumn(
@ -117,7 +117,7 @@ class WorkerTable(BaseTable):
state = tables.Column( state = tables.Column(
verbose_name=_("State") verbose_name=_("State")
) )
birth_date = tables.DateTimeColumn( birth_date = columns.DateTimeColumn(
verbose_name=_("Birth") verbose_name=_("Birth")
) )
pid = tables.Column( pid = tables.Column(

View File

@ -732,7 +732,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
def __str__(self): def __str__(self):
created = timezone.localtime(self.created) created = timezone.localtime(self.created)
return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})" return f"{created.date().isoformat()} {created.time().isoformat(timespec='minutes')} ({self.get_kind_display()})"
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk]) return reverse('extras:journalentry', args=[self.pk])

View File

@ -432,10 +432,10 @@ class ConfigTemplateTable(NetBoxTable):
class ObjectChangeTable(NetBoxTable): class ObjectChangeTable(NetBoxTable):
time = tables.DateTimeColumn( time = columns.DateTimeColumn(
verbose_name=_('Time'), verbose_name=_('Time'),
linkify=True, timespec='minutes',
format=settings.SHORT_DATETIME_FORMAT linkify=True
) )
user_name = tables.Column( user_name = tables.Column(
verbose_name=_('Username') verbose_name=_('Username')
@ -475,10 +475,10 @@ class ObjectChangeTable(NetBoxTable):
class JournalEntryTable(NetBoxTable): class JournalEntryTable(NetBoxTable):
created = tables.DateTimeColumn( created = columns.DateTimeColumn(
verbose_name=_('Created'), verbose_name=_('Created'),
linkify=True, timespec='minutes',
format=settings.SHORT_DATETIME_FORMAT linkify=True
) )
assigned_object_type = columns.ContentTypeColumn( assigned_object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type') verbose_name=_('Object Type')

View File

@ -10,7 +10,6 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template from django.template import Context, Template
from django.urls import reverse from django.urls import reverse
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -52,18 +51,17 @@ __all__ = (
# #
@library.register @library.register
class DateColumn(tables.DateColumn): class DateColumn(tables.Column):
""" """
Overrides the default implementation of DateColumn to better handle null values, returning a default value for Render a datetime.date in ISO 8601 format.
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField.
""" """
def render(self, value): def render(self, value):
if value: if value:
return date_format(value, format="SHORT_DATE_FORMAT") return value.isoformat()
def value(self, value): def value(self, value):
return value if value:
return value.isoformat()
@classmethod @classmethod
def from_field(cls, field, **kwargs): def from_field(cls, field, **kwargs):
@ -72,16 +70,24 @@ class DateColumn(tables.DateColumn):
@library.register @library.register
class DateTimeColumn(tables.DateTimeColumn): class DateTimeColumn(tables.Column):
""" """
Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for Render a datetime.datetime in ISO 8601 format.
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField. Args:
timespec: Granularity specification; passed through to datetime.isoformat()
""" """
def __init__(self, *args, timespec='seconds', **kwargs):
self.timespec = timespec
super().__init__(*args, **kwargs)
def render(self, value):
if value:
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
def value(self, value): def value(self, value):
if value: if value:
return date_format(value, format="SHORT_DATETIME_FORMAT") return value.isoformat()
return None
@classmethod @classmethod
def from_field(cls, field, **kwargs): def from_field(cls, field, **kwargs):
@ -498,7 +504,7 @@ class CustomFieldColumn(tables.Column):
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value: if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
return render_markdown(value) return render_markdown(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value: if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
return date_format(parse_date(value), format="SHORT_DATE_FORMAT") return parse_date(value).isoformat()
if value is not None: if value is not None:
obj = self.customfield.deserialize(value) obj = self.customfield.deserialize(value)
return mark_safe(self._linkify_item(obj)) return mark_safe(self._linkify_item(obj))

View File

@ -31,11 +31,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Account Created" %}</th> <th scope="row">{% trans "Account Created" %}</th>
<td>{{ request.user.date_joined|annotated_date }}</td> <td>{{ request.user.date_joined|isodate }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Last Login" %}</th> <th scope="row">{% trans "Last Login" %}</th>
<td>{{ request.user.last_login|annotated_date }}</td> <td>{{ request.user.last_login|isodatetime:"minutes"|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Superuser" %}</th> <th scope="row">{% trans "Superuser" %}</th>

View File

@ -41,15 +41,15 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Created" %}</th> <th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td> <td>{{ object.created|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Expires" %}</th> <th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td> <td>{{ object.expires|isodatetime|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Last used" %}</th> <th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td> <td>{{ object.last_used|isodatetime|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Allowed IPs" %}</th> <th scope="row">{% trans "Allowed IPs" %}</th>

View File

@ -45,11 +45,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Install Date" %}</th> <th scope="row">{% trans "Install Date" %}</th>
<td>{{ object.install_date|annotated_date|placeholder }}</td> <td>{{ object.install_date|isodate|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Termination Date" %}</th> <th scope="row">{% trans "Termination Date" %}</th>
<td>{{ object.termination_date|annotated_date|placeholder }}</td> <td>{{ object.termination_date|isodate|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Commit Rate" %}</th> <th scope="row">{% trans "Commit Rate" %}</th>

View File

@ -23,7 +23,7 @@
{% block subtitle %} {% block subtitle %}
{% if object.created %} {% if object.created %}
<div class="text-secondary fs-5"> <div class="text-secondary fs-5">
{% trans "Created" %} {{ object.created|annotated_date }} {% trans "Created" %} {{ object.created|isodatetime }}
</div> </div>
{% endif %} {% endif %}
{% endblock subtitle %} {% endblock subtitle %}

View File

@ -9,7 +9,7 @@
{% block subtitle %} {% block subtitle %}
<div class="text-secondary fs-5"> <div class="text-secondary fs-5">
{% trans "Created" %} {{ object.created|annotated_date }} {% trans "Created" %} {{ object.created|isodatetime }}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -49,12 +49,12 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Created" %}</th> <th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td> <td>{{ object.created|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Scheduled" %}</th> <th scope="row">{% trans "Scheduled" %}</th>
<td> <td>
{{ object.scheduled|annotated_date|placeholder }} {{ object.scheduled|isodatetime|placeholder }}
{% if object.interval %} {% if object.interval %}
({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %}) ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %})
{% endif %} {% endif %}
@ -62,11 +62,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Started" %}</th> <th scope="row">{% trans "Started" %}</th>
<td>{{ object.started|annotated_date|placeholder }}</td> <td>{{ object.started|isodatetime|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Completed" %}</th> <th scope="row">{% trans "Completed" %}</th>
<td>{{ object.completed|annotated_date|placeholder }}</td> <td>{{ object.completed|isodatetime|placeholder }}</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -13,7 +13,7 @@
{% block subtitle %} {% block subtitle %}
<div class="text-secondary fs-5"> <div class="text-secondary fs-5">
<span>{% trans "Created" %} {{ job.created_at|annotated_date }}</span> <span>{% trans "Created" %} {{ job.created_at|isodatetime }}</span>
</div> </div>
{% endblock subtitle %} {% endblock subtitle %}
@ -71,11 +71,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Created" %}</th> <th scope="row">{% trans "Created" %}</th>
<td>{{ job.created_at|annotated_date }}</td> <td>{{ job.created_at|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Queued" %}</th> <th scope="row">{% trans "Queued" %}</th>
<td>{{ job.enqueued_at|annotated_date }}</td> <td>{{ job.enqueued_at|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Status" %}</th> <th scope="row">{% trans "Status" %}</th>

View File

@ -11,7 +11,7 @@
{% block subtitle %} {% block subtitle %}
<div class="text-secondary fs-5"> <div class="text-secondary fs-5">
<span>{% trans "Created" %} {{ worker.birth_date|annotated_date }}</span> <span>{% trans "Created" %} {{ worker.birth_date|isodatetime }}</span>
</div> </div>
{% endblock subtitle %} {% endblock subtitle %}
@ -49,7 +49,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Birth" %}</th> <th scope="row">{% trans "Birth" %}</th>
<td>{{ worker.birth_date|annotated_date }}</td> <td>{{ worker.birth_date|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Queues" %}</th> <th scope="row">{% trans "Queues" %}</th>

View File

@ -1,4 +1,3 @@
{% load humanize %}
{% load helpers %} {% load helpers %}
{% load log_levels %} {% load log_levels %}
{% load i18n %} {% load i18n %}
@ -6,11 +5,11 @@
<div class="htmx-container"> <div class="htmx-container">
<p> <p>
{% if job.started %} {% if job.started %}
{% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong> {% trans "Started" %}: <strong>{{ job.started|isodatetime }}</strong>
{% elif job.scheduled %} {% elif job.scheduled %}
{% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }}) {% trans "Scheduled for" %}: <strong>{{ job.scheduled|isodatetime }}</strong>
{% else %} {% else %}
{% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong> {% trans "Created" %}: <strong>{{ job.created|isodatetime }}</strong>
{% endif %} {% endif %}
{% if job.completed %} {% if job.completed %}
{% trans "Duration" %}: <strong>{{ job.duration }}</strong> {% trans "Duration" %}: <strong>{{ job.duration }}</strong>

View File

@ -20,7 +20,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Created" %}</th> <th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td> <td>{{ object.created|isodatetime:"minutes" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Created By" %}</th> <th scope="row">{% trans "Created By" %}</th>

View File

@ -29,9 +29,7 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Time" %}</th> <th scope="row">{% trans "Time" %}</th>
<td> <td>{{ object.time|isodatetime }}</td>
{{ object.time|annotated_date }}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "User" %}</th> <th scope="row">{% trans "User" %}</th>

View File

@ -67,7 +67,7 @@
<td>{{ script.description|markdown|placeholder }}</td> <td>{{ script.description|markdown|placeholder }}</td>
{% if last_job %} {% if last_job %}
<td> <td>
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a> <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
</td> </td>
<td> <td>
{% badge last_job.get_status_display last_job.get_status_color %} {% badge last_job.get_status_display last_job.get_status_color %}

View File

@ -17,7 +17,7 @@
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
<li class="breadcrumb-item"><a href="{{ script.get_absolute_url }}">{{ script }}</a></li> <li class="breadcrumb-item"><a href="{{ script.get_absolute_url }}">{{ script }}</a></li>
<li class="breadcrumb-item">{{ job.created|annotated_date }}</li> <li class="breadcrumb-item">{{ job.created|isodatetime }}</li>
</ol> </ol>
</nav> </nav>
</div> </div>

View File

@ -48,10 +48,10 @@ Context:
{% block subtitle %} {% block subtitle %}
<div class="text-secondary fs-5"> <div class="text-secondary fs-5">
<span>{% trans "Created" %} {{ object.created|annotated_date }}</span> {% trans "Created" %} {{ object.created|isodatetime:"minutes" }}
{% if object.last_updated %} {% if object.last_updated %}
<span class="separator">&middot;</span> <span class="separator">&middot;</span>
<span>{% trans "Updated" %} <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> {% trans "ago" %}</span> {% trans "Updated" %} {{ object.last_updated|isodatetime:"minutes" }}
{% endif %} {% endif %}
</div> </div>
{% endblock subtitle %} {% endblock subtitle %}

View File

@ -37,7 +37,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Date Added" %}</th> <th scope="row">{% trans "Date Added" %}</th>
<td>{{ object.date_added|annotated_date|placeholder }}</td> <td>{{ object.date_added|isodate|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>

View File

@ -1,6 +1,6 @@
{% extends 'generic/object.html' %} {% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %} {% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block title %}{% trans "Token" %} {{ object }}{% endblock %} {% block title %}{% trans "Token" %} {{ object }}{% endblock %}
@ -33,15 +33,15 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Created" %}</th> <th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td> <td>{{ object.created|isodatetime }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Expires" %}</th> <th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td> <td>{{ object.expires|isodatetime|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Last used" %}</th> <th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td> <td>{{ object.last_used|isodatetime|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Allowed IPs" %}</th> <th scope="row">{% trans "Allowed IPs" %}</th>

View File

@ -27,11 +27,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Account Created" %}</th> <th scope="row">{% trans "Account Created" %}</th>
<td>{{ object.date_joined|annotated_date }}</td> <td>{{ object.date_joined|isodate }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Last Login" %}</th> <th scope="row">{% trans "Last Login" %}</th>
<td>{{ object.last_login|annotated_date }}</td> <td>{{ object.last_login|isodatetime:"minutes"|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Active" %}</th> <th scope="row">{% trans "Active" %}</th>

View File

@ -9,9 +9,9 @@
{% elif customfield.type == 'boolean' and value == False %} {% elif customfield.type == 'boolean' and value == False %}
{% checkmark value false="False" %} {% checkmark value false="False" %}
{% elif customfield.type == 'date' and value %} {% elif customfield.type == 'date' and value %}
{{ value|annotated_date }} {{ value|isodate }}
{% elif customfield.type == 'datetime' and value %} {% elif customfield.type == 'datetime' and value %}
{{ value|annotated_date }} {{ value|isodate }} {{ value|isodatetime }}
{% elif customfield.type == 'url' and value %} {% elif customfield.type == 'url' and value %}
<a href="{{ value }}">{{ value|truncatechars:70 }}</a> <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% elif customfield.type == 'json' and value %} {% elif customfield.type == 'json' and value %}

View File

@ -5,6 +5,7 @@ import re
import yaml import yaml
from django import template from django import template
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
@ -20,6 +21,9 @@ __all__ = (
'content_type', 'content_type',
'content_type_id', 'content_type_id',
'fgcolor', 'fgcolor',
'isodate',
'isodatetime',
'isotime',
'linkify', 'linkify',
'meta', 'meta',
'placeholder', 'placeholder',
@ -202,3 +206,36 @@ def render_yaml(value):
{{ data_dict|yaml }} {{ data_dict|yaml }}
""" """
return yaml.dump(json.loads(json.dumps(value))) return yaml.dump(json.loads(json.dumps(value)))
#
# Time & date
#
@register.filter()
def isodate(value):
if type(value) is datetime.date:
text = value.isoformat()
elif type(value) is datetime.datetime:
text = value.date().isoformat()
else:
return ''
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
@register.filter()
def isotime(value, spec='seconds'):
if type(value) is datetime.time:
return value.isoformat(timespec=spec)
if type(value) is datetime.datetime:
return value.time().isoformat(timespec=spec)
return ''
@register.filter()
def isodatetime(value, spec='seconds'):
if type(value) is datetime.datetime:
text = f'{isodate(value)} {isotime(value, spec=spec)}'
else:
return ''
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')