mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	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:
		| @@ -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' | ||||
| ): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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.""" | ||||
|  | ||||
|   | ||||
| @@ -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'), | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user