Report: Add date rendering (#6706)

* Validate timezone in settings.py

* Add helper functions for timezone information

- Extract server timezone
- Convert provided time to specified timezone

* Add more unit tests

* Remove debug print

* Test fix

* Add report helper tags

- format_date
- format_datetime
- Update report templates
- Unit tests

* Add setting to control report errors

- Only log errors to DB if setting is enabled

* Update example report

* Fixes for to_local_time

* Update type hinting

* Fix unit test typo
This commit is contained in:
Oliver 2024-03-14 12:09:14 +11:00 committed by GitHub
parent 7de87383b5
commit 610ea7b0b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 225 additions and 17 deletions

View File

@ -1,5 +1,6 @@
"""Provides helper functions used throughout the InvenTree project."""
import datetime
import hashlib
import io
import json
@ -11,6 +12,7 @@ from decimal import Decimal, InvalidOperation
from typing import TypeVar
from wsgiref.util import FileWrapper
import django.utils.timezone as timezone
from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.exceptions import FieldError, ValidationError
@ -18,6 +20,7 @@ from django.core.files.storage import default_storage
from django.http import StreamingHttpResponse
from django.utils.translation import gettext_lazy as _
import pytz
import regex
from bleach import clean
from djmoney.money import Money
@ -863,6 +866,56 @@ def hash_file(filename: str):
return hashlib.md5(open(filename, 'rb').read()).hexdigest()
def server_timezone() -> str:
"""Return the timezone of the server as a string.
e.g. "UTC" / "Australia/Sydney" etc
"""
return settings.TIME_ZONE
def to_local_time(time, target_tz: str = None):
"""Convert the provided time object to the local timezone.
Arguments:
time: The time / date to convert
target_tz: The desired timezone (string) - defaults to server time
Returns:
A timezone aware datetime object, with the desired timezone
Raises:
TypeError: If the provided time object is not a datetime or date object
"""
if isinstance(time, datetime.datetime):
pass
elif isinstance(time, datetime.date):
time = timezone.datetime(year=time.year, month=time.month, day=time.day)
else:
raise TypeError(
f'Argument must be a datetime or date object (found {type(time)}'
)
# Extract timezone information from the provided time
source_tz = getattr(time, 'tzinfo', None)
if not source_tz:
# Default to UTC if not provided
source_tz = pytz.utc
if not target_tz:
target_tz = server_timezone()
try:
target_tz = pytz.timezone(str(target_tz))
except pytz.UnknownTimeZoneError:
target_tz = pytz.utc
target_time = time.replace(tzinfo=source_tz).astimezone(target_tz)
return target_time
def get_objectreference(
obj, type_ref: str = 'content_type', object_ref: str = 'object_id'
):

View File

@ -22,6 +22,7 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _
import moneyed
import pytz
from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
@ -938,8 +939,13 @@ LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),)
TIME_ZONE = get_setting('INVENTREE_TIMEZONE', 'timezone', 'UTC')
USE_I18N = True
# Check that the timezone is valid
try:
pytz.timezone(TIME_ZONE)
except pytz.exceptions.UnknownTimeZoneError: # pragma: no cover
raise ValueError(f"Specified timezone '{TIME_ZONE}' is not valid")
USE_I18N = True
# Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs

View File

@ -14,8 +14,10 @@ from django.core import mail
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings, tag
from django.urls import reverse
from django.utils import timezone
import pint.errors
import pytz
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
@ -746,6 +748,47 @@ class TestHelpers(TestCase):
self.assertEqual(helpers.generateTestKey(name), key)
class TestTimeFormat(TestCase):
"""Unit test for time formatting functionality."""
@override_settings(TIME_ZONE='UTC')
def test_tz_utc(self):
"""Check UTC timezone."""
self.assertEqual(InvenTree.helpers.server_timezone(), 'UTC')
@override_settings(TIME_ZONE='Europe/London')
def test_tz_london(self):
"""Check London timezone."""
self.assertEqual(InvenTree.helpers.server_timezone(), 'Europe/London')
@override_settings(TIME_ZONE='Australia/Sydney')
def test_to_local_time(self):
"""Test that the local time conversion works as expected."""
source_time = timezone.datetime(
year=2000,
month=1,
day=1,
hour=0,
minute=0,
second=0,
tzinfo=pytz.timezone('Europe/London'),
)
tests = [
('UTC', '2000-01-01 00:01:00+00:00'),
('Europe/London', '2000-01-01 00:00:00-00:01'),
('America/New_York', '1999-12-31 19:01:00-05:00'),
# All following tests should result in the same value
('Australia/Sydney', '2000-01-01 11:01:00+11:00'),
(None, '2000-01-01 11:01:00+11:00'),
('', '2000-01-01 11:01:00+11:00'),
]
for tz, expected in tests:
local_time = InvenTree.helpers.to_local_time(source_time, tz)
self.assertEqual(str(local_time), expected)
class TestQuoteWrap(TestCase):
"""Tests for string wrapping."""

View File

@ -1653,6 +1653,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'REPORT_LOG_ERRORS': {
'name': _('Log Report Errors'),
'description': _('Log errors which occur when generating reports'),
'default': False,
'validator': bool,
},
'REPORT_DEFAULT_PAGE_SIZE': {
'name': _('Page Size'),
'description': _('Default page size for PDF reports'),

View File

@ -264,7 +264,12 @@ class ReportPrintMixin:
except Exception as exc:
# Log the exception to the database
log_error(request.path)
if InvenTree.helpers.str2bool(
common.models.InvenTreeSetting.get_setting(
'REPORT_LOG_ERRORS', cache=False
)
):
log_error(request.path)
# Re-throw the exception to the client as a DRF exception
raise ValidationError({

View File

@ -11,7 +11,7 @@ margin-top: 4cm;
{% endblock page_margin %}
{% block bottom_left %}
content: "v{{ report_revision }} - {{ date.isoformat }}";
content: "v{{ report_revision }} - {% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}

View File

@ -74,7 +74,7 @@ margin-top: 4cm;
{% endblock style %}
{% block bottom_left %}
content: "v{{ report_revision }} - {{ date.isoformat }}";
content: "v{{ report_revision }} - {% format_date date %}";
{% endblock bottom_left %}
{% block header_content %}
@ -119,13 +119,13 @@ content: "v{{ report_revision }} - {{ date.isoformat }}";
</tr>
<tr>
<th>{% trans "Issued" %}</th>
<td>{% render_date build.creation_date %}</td>
<td>{% format_date build.creation_date %}</td>
</tr>
<tr>
<th>{% trans "Target Date" %}</th>
<td>
{% if build.target_date %}
{% render_date build.target_date %}
{% format_date build.target_date %}
{% else %}
<em>Not specified</em>
{% endif %}

View File

@ -12,7 +12,7 @@ margin-top: 4cm;
{% endblock page_margin %}
{% block bottom_left %}
content: "v{{ report_revision }} - {{ date.isoformat }}";
content: "v{{ report_revision }} - {% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}

View File

@ -11,7 +11,7 @@ margin-top: 4cm;
{% endblock page_margin %}
{% block bottom_left %}
content: "v{{ report_revision }} - {{ date.isoformat }}";
content: "v{{ report_revision }} - {% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}

View File

@ -10,7 +10,7 @@
}
{% block bottom_left %}
content: "{{ date.isoformat }}";
content: "{% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}
@ -133,7 +133,7 @@ content: "{% trans 'Stock Item Test Report' %}";
{% endif %}
<td>{{ test_result.value }}</td>
<td>{{ test_result.user.username }}</td>
<td>{{ test_result.date.date.isoformat }}</td>
<td>{% format_date test_result.date.date %}</td>
{% else %}
{% if test_template.required %}
<td colspan='4' class='required-test-not-found'>{% trans "No result (required)" %}</td>

View File

@ -422,3 +422,37 @@ def format_number(number, **kwargs):
pass
return value
@register.simple_tag
def format_datetime(datetime, timezone=None, format=None):
"""Format a datetime object for display.
Arguments:
datetime: The datetime object to format
timezone: The timezone to use for the date (defaults to the server timezone)
format: The format string to use (defaults to ISO formatting)
"""
datetime = InvenTree.helpers.to_local_time(datetime, timezone)
if format:
return datetime.strftime(format)
else:
return datetime.isoformat()
@register.simple_tag
def format_date(date, timezone=None, format=None):
"""Format a date object for display.
Arguments:
date: The date to format
timezone: The timezone to use for the date (defaults to the server timezone)
format: The format string to use (defaults to ISO formatting)
"""
date = InvenTree.helpers.to_local_time(date, timezone).date()
if format:
return date.strftime(format)
else:
return date.isoformat()

View File

@ -8,10 +8,12 @@ from pathlib import Path
from django.conf import settings
from django.core.cache import cache
from django.http.response import StreamingHttpResponse
from django.test import TestCase
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString
import pytz
from PIL import Image
import report.models as report_models
@ -153,6 +155,37 @@ class ReportTagTest(TestCase):
self.assertEqual(report_tags.multiply(2.3, 4), 9.2)
self.assertEqual(report_tags.divide(100, 5), 20)
@override_settings(TIME_ZONE='America/New_York')
def test_date_tags(self):
"""Test for date formatting tags.
- Source timezone is Australia/Sydney
- Server timezone is America/New York
"""
time = timezone.datetime(
year=2024,
month=3,
day=13,
hour=12,
minute=30,
second=0,
tzinfo=pytz.timezone('Australia/Sydney'),
)
# Format a set of tests: timezone, format, expected
tests = [
(None, None, '2024-03-12T22:25:00-04:00'),
(None, '%d-%m-%y', '12-03-24'),
('UTC', None, '2024-03-13T02:25:00+00:00'),
('UTC', '%d-%B-%Y', '13-March-2024'),
('Europe/Amsterdam', None, '2024-03-13T03:25:00+01:00'),
('Europe/Amsterdam', '%y-%m-%d %H:%M', '24-03-13 03:25'),
]
for tz, fmt, expected in tests:
result = report_tags.format_datetime(time, tz, fmt)
self.assertEqual(result, expected)
class BarcodeTagTest(TestCase):
"""Unit tests for the barcode template tags."""

View File

@ -15,6 +15,7 @@
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="fa-file-pdf" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_LOG_ERRORS" icon="fa-exclamation-circle" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
</tbody>

View File

@ -40,7 +40,7 @@ margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
content: "v{{report_revision}} - {% format_date date %}";
{% endblock %}
{% block bottom_center %}

View File

@ -186,7 +186,7 @@ margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
content: "v{{report_revision}} - {% format_date date %}";
{% endblock %}
{% block header_content %}
@ -230,13 +230,13 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
</tr>
<tr>
<th>{% trans "Issued" %}</th>
<td>{% render_date build.creation_date %}</td>
<td>{% format_date build.creation_date %}</td>
</tr>
<tr>
<th>{% trans "Target Date" %}</th>
<td>
{% if build.target_date %}
{% render_date build.target_date %}
{% format_date build.target_date %}
{% else %}
<em>Not specified</em>
{% endif %}

View File

@ -64,7 +64,7 @@ To return an element corresponding to a certain key in a container which support
{% endraw %}
```
## Formatting Numbers
## Number Formatting
The helper function `format_number` allows for some common number formatting options. It takes a number (or a number-like string) as an input, as well as some formatting arguments. It returns a *string* containing the formatted number:
@ -78,7 +78,33 @@ The helper function `format_number` allows for some common number formatting opt
{% endraw %}
```
## Rendering Currency
## Date Formatting
For rendering date and datetime information, the following helper functions are available:
- `format_date`: Format a date object
- `format_datetime`: Format a datetime object
Each of these helper functions takes a date or datetime object as an input, and returns a *string* containing the formatted date or datetime. The following additional arguments are available:
| Argument | Description |
| --- | --- |
| timezone | Specify the timezone to render the date in. If not specified, uses the InvenTree server timezone |
| format | Specify the format string to use for rendering the date. If not specified, uses ISO formatting. Refer to the [datetime format codes](https://docs.python.org/3/library/datetime.html#format-codes) for more information! |
### Example
A simple example of using the date formatting helper functions:
```html
{% raw %}
{% load report %}
Date: {% format_date my_date timezone="Australia/Sydney" %}
Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %}
{% endraw %}
```
## Currency Formatting
The helper function `render_currency` allows for simple rendering of currency data. This function can also convert the specified amount of currency into a different target currency:

View File

@ -152,6 +152,7 @@ export default function SystemSettings() {
'REPORT_ENABLE',
'REPORT_DEFAULT_PAGE_SIZE',
'REPORT_DEBUG_MODE',
'REPORT_LOG_ERRORS',
'REPORT_ENABLE_TEST_REPORT',
'REPORT_ATTACH_TEST_REPORT'
]}