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'
]}
|