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.""" """Provides helper functions used throughout the InvenTree project."""
import datetime
import hashlib import hashlib
import io import io
import json import json
@ -11,6 +12,7 @@ from decimal import Decimal, InvalidOperation
from typing import TypeVar from typing import TypeVar
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
import django.utils.timezone as timezone
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.storage import StaticFilesStorage from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.exceptions import FieldError, ValidationError 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.http import StreamingHttpResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import pytz
import regex import regex
from bleach import clean from bleach import clean
from djmoney.money import Money from djmoney.money import Money
@ -863,6 +866,56 @@ def hash_file(filename: str):
return hashlib.md5(open(filename, 'rb').read()).hexdigest() 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( def get_objectreference(
obj, type_ref: str = 'content_type', object_ref: str = 'object_id' 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 _ from django.utils.translation import gettext_lazy as _
import moneyed import moneyed
import pytz
from dotenv import load_dotenv from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting 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') 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 # Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs # 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.core.exceptions import ValidationError
from django.test import TestCase, override_settings, tag from django.test import TestCase, override_settings, tag
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
import pint.errors import pint.errors
import pytz
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
@ -746,6 +748,47 @@ class TestHelpers(TestCase):
self.assertEqual(helpers.generateTestKey(name), key) 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): class TestQuoteWrap(TestCase):
"""Tests for string wrapping.""" """Tests for string wrapping."""

View File

@ -1653,6 +1653,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, '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': { 'REPORT_DEFAULT_PAGE_SIZE': {
'name': _('Page Size'), 'name': _('Page Size'),
'description': _('Default page size for PDF reports'), 'description': _('Default page size for PDF reports'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
} }
{% block bottom_left %} {% block bottom_left %}
content: "{{ date.isoformat }}"; content: "{% format_date date %}";
{% endblock bottom_left %} {% endblock bottom_left %}
{% block bottom_center %} {% block bottom_center %}
@ -133,7 +133,7 @@ content: "{% trans 'Stock Item Test Report' %}";
{% endif %} {% endif %}
<td>{{ test_result.value }}</td> <td>{{ test_result.value }}</td>
<td>{{ test_result.user.username }}</td> <td>{{ test_result.user.username }}</td>
<td>{{ test_result.date.date.isoformat }}</td> <td>{% format_date test_result.date.date %}</td>
{% else %} {% else %}
{% if test_template.required %} {% if test_template.required %}
<td colspan='4' class='required-test-not-found'>{% trans "No result (required)" %}</td> <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 pass
return value 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.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http.response import StreamingHttpResponse 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.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
import pytz
from PIL import Image from PIL import Image
import report.models as report_models 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.multiply(2.3, 4), 9.2)
self.assertEqual(report_tags.divide(100, 5), 20) 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): class BarcodeTagTest(TestCase):
"""Unit tests for the barcode template tags.""" """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_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_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_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_ENABLE_TEST_REPORT" icon="fa-vial" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ATTACH_TEST_REPORT" icon="fa-file-upload" %}
</tbody> </tbody>

View File

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

View File

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

View File

@ -64,7 +64,7 @@ To return an element corresponding to a certain key in a container which support
{% endraw %} {% 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: 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 %} {% 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: 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_ENABLE',
'REPORT_DEFAULT_PAGE_SIZE', 'REPORT_DEFAULT_PAGE_SIZE',
'REPORT_DEBUG_MODE', 'REPORT_DEBUG_MODE',
'REPORT_LOG_ERRORS',
'REPORT_ENABLE_TEST_REPORT', 'REPORT_ENABLE_TEST_REPORT',
'REPORT_ATTACH_TEST_REPORT' 'REPORT_ATTACH_TEST_REPORT'
]} ]}