From 610ea7b0b1b6316bc66883b429b3870e1d011cd4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 14 Mar 2024 12:09:14 +1100 Subject: [PATCH] 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 --- InvenTree/InvenTree/helpers.py | 53 +++++++++++++++++++ InvenTree/InvenTree/settings.py | 8 ++- InvenTree/InvenTree/tests.py | 43 +++++++++++++++ InvenTree/common/models.py | 6 +++ InvenTree/report/api.py | 7 ++- .../inventree_bill_of_materials_report.html | 2 +- .../report/inventree_build_order_base.html | 6 +-- .../report/inventree_order_report_base.html | 2 +- .../report/inventree_slr_report.html | 2 +- .../report/inventree_test_report_base.html | 4 +- InvenTree/report/templatetags/report.py | 34 ++++++++++++ InvenTree/report/tests.py | 35 +++++++++++- .../templates/InvenTree/settings/report.html | 1 + docs/docs/report/bom.md | 2 +- docs/docs/report/build.md | 6 +-- docs/docs/report/helpers.md | 30 ++++++++++- .../pages/Index/Settings/SystemSettings.tsx | 1 + 17 files changed, 225 insertions(+), 17 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 20fca9cf8f..1e30999cec 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -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' ): diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index e7596eed44..071b01be7c 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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 diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index bd5ebf709b..45516eba2e 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -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.""" diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8c28306ad6..b4a175a071 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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'), diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 2b39a96e84..a9bfec0952 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -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({ diff --git a/InvenTree/report/templates/report/inventree_bill_of_materials_report.html b/InvenTree/report/templates/report/inventree_bill_of_materials_report.html index f2dd287b5b..8f34911bfc 100644 --- a/InvenTree/report/templates/report/inventree_bill_of_materials_report.html +++ b/InvenTree/report/templates/report/inventree_bill_of_materials_report.html @@ -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 %} diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 23b76f85b2..dfb177a9fa 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -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 }}"; {% trans "Issued" %} - {% render_date build.creation_date %} + {% format_date build.creation_date %} {% trans "Target Date" %} {% if build.target_date %} - {% render_date build.target_date %} + {% format_date build.target_date %} {% else %} Not specified {% endif %} diff --git a/InvenTree/report/templates/report/inventree_order_report_base.html b/InvenTree/report/templates/report/inventree_order_report_base.html index ceaf3edd7e..6f936681dc 100644 --- a/InvenTree/report/templates/report/inventree_order_report_base.html +++ b/InvenTree/report/templates/report/inventree_order_report_base.html @@ -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 %} diff --git a/InvenTree/report/templates/report/inventree_slr_report.html b/InvenTree/report/templates/report/inventree_slr_report.html index f10c74d318..f2e13ff843 100644 --- a/InvenTree/report/templates/report/inventree_slr_report.html +++ b/InvenTree/report/templates/report/inventree_slr_report.html @@ -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 %} diff --git a/InvenTree/report/templates/report/inventree_test_report_base.html b/InvenTree/report/templates/report/inventree_test_report_base.html index 3afcdb474b..4e25b4598f 100644 --- a/InvenTree/report/templates/report/inventree_test_report_base.html +++ b/InvenTree/report/templates/report/inventree_test_report_base.html @@ -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 %} {{ test_result.value }} {{ test_result.user.username }} - {{ test_result.date.date.isoformat }} + {% format_date test_result.date.date %} {% else %} {% if test_template.required %} {% trans "No result (required)" %} diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index e66cc326a9..6917271a40 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -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() diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index ab9b68f83c..2296a2f5db 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -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.""" diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html index baf57c9bfc..683625bd74 100644 --- a/InvenTree/templates/InvenTree/settings/report.html +++ b/InvenTree/templates/InvenTree/settings/report.html @@ -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" %} diff --git a/docs/docs/report/bom.md b/docs/docs/report/bom.md index f6f2864e01..13de616082 100644 --- a/docs/docs/report/bom.md +++ b/docs/docs/report/bom.md @@ -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 %} diff --git a/docs/docs/report/build.md b/docs/docs/report/build.md index f1b138f5eb..ab6b740d7e 100644 --- a/docs/docs/report/build.md +++ b/docs/docs/report/build.md @@ -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 }}"; {% trans "Issued" %} - {% render_date build.creation_date %} + {% format_date build.creation_date %} {% trans "Target Date" %} {% if build.target_date %} - {% render_date build.target_date %} + {% format_date build.target_date %} {% else %} Not specified {% endif %} diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index 6aa5475e23..b275ede4b9 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -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: diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 8a5d50cbd2..e449d08ecb 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -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' ]}