Coverage for src/backend/InvenTree/report/templatetags/report.py: 18%
562 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Custom template tags for report generation."""
3import base64
4import copy
5import logging
6import mimetypes
7from datetime import date, datetime
8from decimal import Decimal, InvalidOperation
9from io import BytesIO
10from pathlib import Path
11from typing import Any, Optional
13from django import template
14from django.apps.registry import apps
15from django.conf import settings
16from django.contrib.staticfiles.storage import staticfiles_storage
17from django.core.exceptions import SuspiciousFileOperation, ValidationError
18from django.core.files.storage import default_storage
19from django.db.models import Model
20from django.db.models.query import QuerySet
21from django.utils import translation
22from django.utils.safestring import SafeString, mark_safe
23from django.utils.translation import gettext_lazy as _
25from babel import Locale
26from babel.core import UnknownLocaleError
27from babel.dates import format_date as babel_format_date
28from babel.dates import format_datetime as babel_format_datetime
29from babel.numbers import format_decimal as babel_format_decimal
30from babel.numbers import parse_pattern
31from djmoney.contrib.exchange.exceptions import MissingRate
32from djmoney.contrib.exchange.models import convert_money
33from djmoney.money import Money
34from PIL import Image
36import common.currency
37import common.icons
38import common.models
39import InvenTree.helpers
40import InvenTree.helpers_model
41import report.helpers
42from common.settings import get_global_setting
43from company.models import Company
44from part.models import Part
46register = template.Library()
49logger = logging.getLogger('inventree')
52def get_locale(locale: Optional[str] = None) -> Locale:
53 """Resolve and return a babel Locale.
55 Args:
56 locale: Optional locale string (e.g. 'en-us'). Falls back to LANGUAGE_CODE.
58 Raises:
59 ValidationError: If the locale string is invalid.
60 """
61 language = locale or settings.LANGUAGE_CODE
62 try:
63 return Locale.parse(translation.to_locale(language))
64 except (UnknownLocaleError, ValueError) as e:
65 raise ValidationError(f"Invalid locale '{language}' - {e}")
68@register.simple_tag()
69def order_queryset(queryset: QuerySet, *args) -> QuerySet:
70 """Order a database queryset based on the provided arguments.
72 Arguments:
73 queryset: The queryset to order
75 Keyword Arguments:
76 field (str): Order the queryset based on the provided field
78 Example:
79 {% order_queryset companies 'name' as ordered_companies %}
80 """
81 if not isinstance(queryset, QuerySet):
82 return queryset
84 return queryset.order_by(*args)
87@register.simple_tag()
88def filter_queryset(queryset: QuerySet, **kwargs) -> QuerySet:
89 """Filter a database queryset based on the provided keyword arguments.
91 Arguments:
92 queryset: The queryset to filter
94 Keyword Arguments:
95 field (any): Filter the queryset based on the provided field
97 Example:
98 {% filter_queryset companies is_supplier=True as suppliers %}
99 """
100 if not isinstance(queryset, QuerySet):
101 return queryset
102 return queryset.filter(**kwargs)
105@register.simple_tag()
106def filter_db_model(model_name: str, **kwargs) -> Optional[QuerySet]:
107 """Filter a database model based on the provided keyword arguments.
109 Arguments:
110 model_name: The name of the Django model - including app name (e.g. 'part.partcategory')
112 Keyword Arguments:
113 field (any): Filter the queryset based on the provided field
115 Example:
116 {% filter_db_model 'part.partcategory' is_template=True as template_parts %}
117 """
118 try:
119 app_name, model_name = model_name.split('.')
120 except ValueError:
121 return None
123 try:
124 model = apps.get_model(app_name, model_name)
125 except Exception:
126 return None
128 if model is None:
129 return None
131 queryset = model.objects.all()
133 return filter_queryset(queryset, **kwargs)
136@register.simple_tag()
137def getindex(container: list, index: int) -> Any:
138 """Return the value contained at the specified index of the list.
140 This function is provided to get around template rendering limitations.
142 Arguments:
143 container: A python list object
144 index: The index to retrieve from the list
145 """
146 # Index *must* be an integer
147 try:
148 index = int(index)
149 except ValueError:
150 return None
152 if index < 0 or index >= len(container):
153 return None
154 return container[index]
157@register.simple_tag()
158def getkey(container: dict, key: str, backup_value: Optional[Any] = None) -> Any:
159 """Perform key lookup in the provided dict object.
161 This function is provided to get around template rendering limitations.
162 Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates
164 Arguments:
165 container: A python dict object
166 key: The 'key' to be found within the dict
167 backup_value: A backup value to return if the key is not found
168 """
169 if type(container) is not dict:
170 logger.warning('getkey() called with non-dict object')
171 return None
173 return container.get(key, backup_value)
176def media_file_exists(path: Path | str) -> bool:
177 """Check if a media file exists at the specified path.
179 Arguments:
180 path: The path to the media file, relative to the media storage root
182 Returns:
183 True if the file exists, False otherwise
184 """
185 if not path:
186 return False
188 try:
189 return default_storage.exists(str(path))
190 except SuspiciousFileOperation:
191 # Prevent path traversal attacks
192 raise ValidationError(_('Invalid media file path') + f": '{path}'")
195def static_file_exists(path: Path | str) -> bool:
196 """Check if a static file exists at the specified path.
198 Arguments:
199 path: The path to the static file, relative to the static storage root
201 Returns:
202 True if the file exists, False otherwise
203 """
204 if not path:
205 return False
207 try:
208 return staticfiles_storage.exists(str(path))
209 except SuspiciousFileOperation:
210 # Prevent path traversal attacks
211 raise ValidationError(_('Invalid static file path') + f": '{path}'")
214def get_static_file_contents(
215 path: Path | str, raise_error: bool = False
216) -> bytes | None:
217 """Return the contents of a static file.
219 Arguments:
220 path: The path to the static file, relative to the static storage root
221 raise_error: If True, raise an error if the file cannot be found (default = False)
223 Returns:
224 The contents of the static file, or None if the file cannot be found
225 """
226 if not path:
227 if raise_error:
228 raise ValueError('No static file specified')
229 else:
230 return None
232 if not staticfiles_storage.exists(path):
233 if raise_error:
234 raise FileNotFoundError(f'Static file does not exist: {path!s}')
235 else:
236 return None
238 with staticfiles_storage.open(str(path)) as f:
239 file_data = f.read()
241 return file_data
244def get_media_file_contents(
245 path: Path | str, raise_error: bool = False
246) -> bytes | None:
247 """Return the fully qualified file path to an uploaded media file.
249 Arguments:
250 path: The path to the media file, relative to the media storage root
251 raise_error: If True, raise an error if the file cannot be found (default = False)
253 Returns:
254 The contents of the media file, or None if the file cannot be found
256 Raises:
257 FileNotFoundError: If the requested media file cannot be loaded
258 PermissionError: If the requested media file is outside of the media root
259 ValidationError: If the provided path is invalid
261 Notes:
262 - The resulting path is resolved against the media root directory
263 """
264 if not path:
265 if raise_error:
266 raise ValueError('No media file specified')
267 else:
268 return None
270 if not media_file_exists(path):
271 if raise_error:
272 raise FileNotFoundError(f'Media file does not exist: {path!s}')
273 else:
274 return None
276 # Load the file - and return the contents
277 with default_storage.open(str(path)) as f:
278 file_data = f.read()
280 return file_data
283@register.simple_tag()
284def asset(filename: str, raise_error: bool = False) -> str | None:
285 """Return fully-qualified path for an upload report asset file.
287 Arguments:
288 filename: Asset filename (relative to the 'assets' media directory)
289 raise_error: If True, raise an error if the file cannot be found (default = False)
291 Raises:
292 FileNotFoundError: If file does not exist
293 ValueError: If an invalid filename is provided (e.g. empty string)
294 ValidationError: If the filename is invalid (e.g. path traversal attempt)
295 """
296 if not filename:
297 if raise_error:
298 raise ValueError('No asset file specified')
299 else:
300 return None
302 if type(filename) is SafeString:
303 # Prepend an empty string to enforce 'stringiness'
304 filename = '' + filename
306 # Remove any leading slash characters from the filename, to prevent path traversal attacks
307 filename = str(filename).lstrip('/\\')
309 full_path = Path('report', 'assets', filename)
311 if not media_file_exists(full_path):
312 if raise_error:
313 raise FileNotFoundError(_('Asset file not found') + f": '{filename}'")
314 else:
315 return None
317 # In debug mode, return a web URL to the asset file (rather than encoded data)
318 if get_global_setting('REPORT_DEBUG_MODE', cache=False):
319 return default_storage.url(str(full_path))
321 file_data = get_media_file_contents(full_path, raise_error=raise_error)
323 if not file_data:
324 return None
326 mime_type, _encoding = mimetypes.guess_type(str(filename))
327 if not mime_type:
328 mime_type = 'application/octet-stream'
330 encoded = base64.b64encode(file_data).decode('ascii')
331 return f'data:{mime_type};base64,{encoded}'
334@register.simple_tag()
335def uploaded_image(
336 filename: str,
337 replace_missing: bool = True,
338 replacement_file: str = 'blank_image.png',
339 validate: bool = True,
340 width: Optional[int] = None,
341 height: Optional[int] = None,
342 rotate: Optional[float] = None,
343 raise_error: bool = False,
344 **kwargs,
345) -> str:
346 """Return raw image data from an 'uploaded' image.
348 Arguments:
349 filename: The filename of the image relative to the media root directory
350 replace_missing: Optionally return a placeholder image if the provided filename does not exist (default = True)
351 replacement_file: The filename of the placeholder image (default = 'blank_image.png')
352 validate: Optionally validate that the file is a valid image file
353 width: Optional width of the image
354 height: Optional height of the image
355 rotate: Optional rotation to apply to the image
356 raise_error: If True, raise an error if the file cannot be found (default = False)
358 Returns:
359 Binary image data to be rendered directly in a <img> tag
361 Raises:
362 FileNotFoundError: If the file does not exist
363 ValueError: If an invalid filename is provided (e.g. empty string)
364 """
365 if type(filename) is SafeString:
366 # Prepend an empty string to enforce 'stringiness'
367 filename = '' + filename
369 # Strip out any leading slash characters from the filename, to prevent path traversal attacks
370 filename = str(filename).lstrip('/\\')
372 # If in debug mode, return URL to the image, not a local file
373 debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False)
375 # Load image data - this will check if the file exists
376 exists = bool(filename) and media_file_exists(filename)
378 if not exists and not replace_missing:
379 raise FileNotFoundError(_('Image file not found') + f": '{filename}'")
381 if exists:
382 img_data = get_media_file_contents(filename, raise_error=raise_error)
384 # Check if the image data is valid
385 if (
386 img_data
387 and validate
388 and not InvenTree.helpers.TestIfImage(BytesIO(img_data))
389 ):
390 logger.warning("File '%s' is not a valid image", filename)
391 img_data = None
392 exists = False
393 else:
394 # Load the backup image from the static files directory
395 replacement_file_path = Path('img', replacement_file)
396 img_data = get_static_file_contents(
397 replacement_file_path, raise_error=raise_error
398 )
400 if debug_mode:
401 # In debug mode, return a web path (rather than an encoded image blob)
402 if exists:
403 return default_storage.url(filename)
405 return staticfiles_storage.url(str(Path('img', replacement_file)))
407 if img_data:
408 img = Image.open(BytesIO(img_data))
409 else:
410 # A placeholder image showing that the image is missing
411 img = Image.new('RGB', (64, 64), color='red')
413 if width is not None:
414 try:
415 width = int(width)
416 except ValueError:
417 width = None
419 if height is not None:
420 try:
421 height = int(height)
422 except ValueError:
423 height = None
425 if width is not None and height is not None:
426 # Resize the image, width *and* height are provided
427 img = img.resize((width, height))
428 elif width is not None:
429 # Resize the image, width only
430 wpercent = width / float(img.size[0])
431 hsize = int(float(img.size[1]) * float(wpercent))
432 img = img.resize((width, hsize))
433 elif height is not None:
434 # Resize the image, height only
435 hpercent = height / float(img.size[1])
436 wsize = int(float(img.size[0]) * float(hpercent))
437 img = img.resize((wsize, height))
439 # Optionally rotate the image
440 if rotate is not None:
441 try:
442 rotate = int(rotate)
443 img = img.rotate(rotate)
444 except ValueError:
445 pass
447 # Return a base-64 encoded image
448 img_data = report.helpers.encode_image_base64(img)
450 return img_data
453@register.simple_tag()
454def encode_svg_image(filename: str) -> str:
455 """Return a base64-encoded svg image data string."""
456 if type(filename) is SafeString:
457 # Prepend an empty string to enforce 'stringiness'
458 filename = '' + filename
460 # Remove any leading slash characters from the filename, to prevent path traversal attacks
461 filename = str(filename).lstrip('/\\')
463 if not filename:
464 raise FileNotFoundError(_('No image file specified'))
466 # Read out the file contents
467 # Note: This will check if the file exists, and raise an error if it does not
468 data = get_media_file_contents(filename)
470 # Return the base64-encoded data
471 return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode(
472 'utf-8'
473 )
476@register.simple_tag()
477def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwargs):
478 """Return a fully-qualified path for a part image.
480 Arguments:
481 part: A Part model instance
482 preview: Return the preview image (default = False)
483 thumbnail: Return the thumbnail image (default = False)
485 Raises:
486 TypeError: If provided part is not a Part instance
487 """
488 if not part or not isinstance(part, Part):
489 raise ValidationError(_('part_image tag requires a Part instance'))
491 image_filename = InvenTree.helpers.image2name(part.image, preview, thumbnail)
493 if kwargs.get('check_exists'):
494 if not media_file_exists(image_filename):
495 raise FileNotFoundError(_('Image file not found') + f": '{image_filename}'")
497 return uploaded_image(
498 InvenTree.helpers.image2name(part.image, preview, thumbnail), **kwargs
499 )
502@register.simple_tag()
503def parameter(
504 instance: Model, parameter_name: str
505) -> Optional[common.models.Parameter]:
506 """Return a Parameter object for the given part and parameter name.
508 Arguments:
509 instance: A Model object
510 parameter_name: The name of the parameter to retrieve (case insensitive)
512 Returns:
513 A Parameter object, or the provided default value if not found
514 """
515 if instance is None or not isinstance(instance, Model):
516 raise ValidationError('parameter tag requires a valid Model instance')
518 if not hasattr(instance, 'parameters'):
519 raise ValidationError(
520 "parameter tag requires a Model with 'parameters' attribute"
521 )
523 parameters = instance.parameters_list.all().prefetch_related('template')
525 # First try with exact match
526 if parameter := parameters.filter(template__name=parameter_name).first():
527 return parameter
529 # Next, try with case-insensitive match
530 if parameter := parameters.filter(template__name__iexact=parameter_name).first():
531 return parameter
533 return None
536@register.simple_tag()
537def parameter_value(
538 instance: Model, parameter_name: str, backup_value: Optional[Any] = None
539) -> str:
540 """Return the value of a Parameter for the given part and parameter name.
542 Arguments:
543 instance: A Model object
544 parameter_name: The name of the parameter to retrieve (case insensitive)
545 backup_value: A backup value to return if the parameter is not found
547 Returns:
548 The value of the Parameter, or the backup_value if not found
549 """
550 if param := parameter(instance, parameter_name):
551 return param.data
553 # If the matching parameter is not found, return the backup value
554 return backup_value
557@register.simple_tag()
558def part_parameter(instance, parameter_name):
559 """Included for backwards compatibility - use 'parameter' tag instead.
561 Ref: https://github.com/inventree/InvenTree/pull/10699
562 """
563 return parameter(instance, parameter_name)
566@register.simple_tag()
567def company_image(
568 company: Company, preview: bool = False, thumbnail: bool = False, **kwargs
569) -> str:
570 """Return a fully-qualified path for a company image.
572 Arguments:
573 company: A Company model instance
574 preview: Return the preview image (default = False)
575 thumbnail: Return the thumbnail image (default = False)
577 Raises:
578 TypeError: If provided company is not a Company instance
579 """
580 if type(company) is not Company:
581 raise TypeError(_('company_image tag requires a Company instance'))
582 return uploaded_image(
583 InvenTree.helpers.image2name(company.image, preview, thumbnail), **kwargs
584 )
587@register.simple_tag()
588def logo_image(**kwargs) -> str:
589 """Return a fully-qualified path for the logo image.
591 - If a custom logo has been provided, return a path to that logo
592 - Otherwise, return a path to the default InvenTree logo
593 """
594 # If in debug mode, return URL to the image, not a local file
595 debug_mode = get_global_setting('REPORT_DEBUG_MODE', cache=False)
597 return InvenTree.helpers.getLogoImage(as_file=not debug_mode, **kwargs)
600@register.simple_tag()
601def internal_link(link, text) -> str:
602 """Make a <a></a> href which points to an InvenTree URL.
604 Uses the InvenTree.helpers_model.construct_absolute_url function to build the URL.
605 """
606 text = str(text)
608 try:
609 url = InvenTree.helpers_model.construct_absolute_url(link)
610 except Exception:
611 url = None
613 # If the base URL is not set, just return the text
614 if not url:
615 logger.warning('Failed to construct absolute URL for internal link')
616 return text
618 return mark_safe(f'<a href="{url}">{text}</a>')
621def make_decimal(value: Any) -> Any:
622 """Convert an input value into a Decimal.
624 - Converts [string, int, float] types into Decimal
625 - If conversion fails, returns the original value
627 The purpose of this function is to provide "seamless" math operations in templates,
628 where numeric values may be provided as strings, or converted to strings during template rendering.
629 """
630 if any(isinstance(value, t) for t in [int, float, str]):
631 try:
632 value = Decimal(str(value).strip())
633 except (InvalidOperation, TypeError, ValueError):
634 logger.warning(
635 'make_decimal: Failed to convert value to Decimal: %s (%s)',
636 value,
637 type(value),
638 )
640 return value
643def cast_to_type(value: Any, cast: type) -> Any:
644 """Attempt to cast a value to the provided type.
646 If casting fails, the original value is returned.
647 """
648 if cast is not None:
649 try:
650 value = cast(value)
651 except (ValueError, TypeError):
652 pass
654 return value
657def debug_vars(x: Any, y: Any) -> str:
658 """Return a debug string showing the types and values of two variables."""
659 return f"x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
662def check_nulls(func: str, *arg):
663 """Check if any of the provided arguments is null.
665 Raises:
666 ValueError: If any argument is None
667 """
668 if any(a is None for a in arg):
669 raise ValidationError(f'{func}: {_("Null value provided to function")}')
672@register.simple_tag()
673def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
674 """Add two numbers (or number like values) together.
676 Arguments:
677 x: The first value to add
678 y: The second value to add
679 cast: Optional type to cast the result to (e.g. int, float, str)
681 Raises:
682 ValidationError: If the values cannot be added together
683 """
684 check_nulls('add', x, y)
686 try:
687 result = make_decimal(x) + make_decimal(y)
688 except (InvalidOperation, TypeError, ValueError):
689 raise ValidationError(
690 f'add: {_("Cannot add values of incompatible types")}: {debug_vars(x, y)}'
691 )
692 return cast_to_type(result, cast)
695@register.simple_tag()
696def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
697 """Subtract one number (or number-like value) from another.
699 Arguments:
700 x: The value to be subtracted from
701 y: The value to be subtracted
702 cast: Optional type to cast the result to (e.g. int, float, str)
704 Raises:
705 ValidationError: If the values cannot be subtracted
706 """
707 check_nulls('subtract', x, y)
709 try:
710 result = make_decimal(x) - make_decimal(y)
711 except (InvalidOperation, TypeError, ValueError):
712 raise ValidationError(
713 f'subtract: {_("Cannot subtract values of incompatible types")}: {debug_vars(x, y)}'
714 )
716 return cast_to_type(result, cast)
719@register.simple_tag()
720def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
721 """Multiply two numbers (or number-like values) together.
723 Arguments:
724 x: The first value to multiply
725 y: The second value to multiply
726 cast: Optional type to cast the result to (e.g. int, float, str)
728 Raises:
729 ValidationError: If the values cannot be multiplied together
730 """
731 check_nulls('multiply', x, y)
733 try:
734 result = make_decimal(x) * make_decimal(y)
735 except (InvalidOperation, TypeError, ValueError):
736 raise ValidationError(
737 f'multiply: {_("Cannot multiply values of incompatible types")}: {debug_vars(x, y)}'
738 )
740 return cast_to_type(result, cast)
743@register.simple_tag()
744def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
745 """Divide one number (or number-like value) by another.
747 Arguments:
748 x: The value to be divided
749 y: The value to divide by
750 cast: Optional type to cast the result to (e.g. int, float, str)
752 Raises:
753 ValidationError: If the values cannot be divided
754 """
755 check_nulls('divide', x, y)
757 try:
758 result = make_decimal(x) / make_decimal(y)
759 except (InvalidOperation, TypeError, ValueError):
760 raise ValidationError(
761 f'divide: {_("Cannot divide values of incompatible types")}: {debug_vars(x, y)}'
762 )
763 except ZeroDivisionError:
764 raise ValidationError(
765 f'divide: {_("Cannot divide by zero")}: {debug_vars(x, y)}'
766 )
768 return cast_to_type(result, cast)
771@register.simple_tag()
772def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
773 """Calculate the modulo of one number (or number-like value) by another.
775 Arguments:
776 x: The first value to be used in the modulo operation
777 y: The second value to be used in the modulo operation
778 cast: Optional type to cast the result to (e.g. int, float, str)
780 Raises:
781 ValidationError: If the values cannot be used in a modulo operation
782 """
783 check_nulls('modulo', x, y)
785 try:
786 result = make_decimal(x) % make_decimal(y)
787 except (InvalidOperation, TypeError, ValueError):
788 raise ValidationError(
789 f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
790 )
791 except ZeroDivisionError:
792 raise ValidationError(
793 f'modulo: {_("Cannot perform modulo operation with divisor of zero")}: {debug_vars(x, y)}'
794 )
796 return cast_to_type(result, cast)
799@register.simple_tag
800def render_currency(
801 money: Money | str | int | float | Decimal,
802 decimal_places: Optional[int] = None,
803 currency: Optional[str] = None,
804 multiplier: Optional[Decimal] = None,
805 max_decimal_places: Optional[int] = None,
806 include_symbol: bool = True,
807 leading: Optional[int] = None,
808 fmt: Optional[str] = None,
809 locale: Optional[str] = None,
810 **kwargs,
811) -> str:
812 """Render a currency / Money object to a formatted string.
814 Arguments:
815 money: The Money instance to be rendered
816 currency: Optionally convert to the specified currency before rendering
817 multiplier: Optional multiplier to apply to the amount before rendering
818 decimal_places: Minimum (forced) decimal places, e.g. decimal_places=2 gives '.00'. Defaults to the locale/currency standard.
819 max_decimal_places: Maximum decimal places (optional digits beyond decimal_places), e.g. max_decimal_places=4 allows up to 4.
820 include_symbol: If True, include the currency symbol in the output
821 leading: Minimum number of leading digits to render before the decimal point (default = 1)
822 fmt: Optional Babel number pattern string. When provided, takes priority over all other formatting options.
823 locale: Optional locale override (e.g. 'en-us', 'de-de'). Defaults to server LANGUAGE_CODE.
824 """
825 if money in [None, '']:
826 return '-'
828 # If the supplied value is *not* a Money instance, attempt to convert it into one
829 if not isinstance(money, Money):
830 try:
831 money = Money(
832 Decimal(str(money)),
833 currency or get_global_setting('INVENTREE_DEFAULT_CURRENCY'),
834 )
835 except Exception:
836 raise ValidationError(f'render_currency: invalid money value - {money!r}')
838 if currency is not None:
839 try:
840 money = convert_money(money, currency)
841 except Exception:
842 pass
844 if multiplier is not None:
845 try:
846 money *= Decimal(str(multiplier).strip())
847 except Exception:
848 raise ValidationError(
849 f'render_currency: invalid multiplier value - {multiplier!r}'
850 )
852 locale = get_locale(locale)
854 # If a custom fmt pattern is applied, that overrides other formatting options
855 if fmt:
856 pattern = parse_pattern(fmt)
857 return pattern.apply(
858 money.amount,
859 locale,
860 currency=money.currency.code if include_symbol else '',
861 currency_digits=False,
862 decimal_quantization=True,
863 )
865 pattern = copy.copy(locale.currency_formats['standard'])
867 if decimal_places is None or not isinstance(decimal_places, (int, float)):
868 decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)
870 if max_decimal_places is None or not isinstance(max_decimal_places, (int, float)):
871 max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
873 pattern.frac_prec = (decimal_places, max(decimal_places, max_decimal_places))
875 if leading is not None:
876 try:
877 leading = int(leading) or 0
878 except (ValueError, TypeError):
879 leading = 0
880 if leading > 0:
881 min_int, max_int = pattern.int_prec
882 pattern.int_prec = (max(leading, min_int), max(leading, max_int))
884 return pattern.apply(
885 money.amount,
886 locale,
887 currency=money.currency.code if include_symbol else '',
888 currency_digits=decimal_places is None and max_decimal_places is None,
889 decimal_quantization=decimal_places is not None
890 or max_decimal_places is not None,
891 )
894@register.simple_tag
895def create_currency(
896 amount: str | int | float | Decimal, currency: Optional[str] = None, **kwargs
897):
898 """Create a Money object, with the provided amount and currency.
900 Arguments:
901 amount: The numeric amount (a numeric type or string)
902 currency: The currency code (e.g. 'USD', 'EUR', etc.)
904 Note: If the currency is not provided, the default system currency will be used.
905 """
906 check_nulls('create_currency', amount)
908 currency = currency or common.currency.currency_code_default()
909 currency = currency.strip().upper()
911 if currency not in common.currency.CURRENCIES:
912 raise ValidationError(
913 f'create_currency: {_("Invalid currency code")}: {currency}'
914 )
916 try:
917 money = Money(amount, currency)
918 except InvalidOperation:
919 raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}')
921 return money
924@register.simple_tag
925def convert_currency(money: Money, currency: Optional[str] = None, **kwargs):
926 """Convert a Money object to the specified currency.
928 Arguments:
929 money: The Money instance to be converted
930 currency: The target currency code (e.g. 'USD', 'EUR', etc.)
932 Note: If the currency is not provided, the default system currency will be used.
933 """
934 check_nulls('convert_currency', money)
936 if not isinstance(money, Money):
937 raise TypeError('convert_currency tag requires a Money instance')
939 currency = currency or common.currency.currency_code_default()
940 currency = currency.strip().upper()
942 if currency not in common.currency.CURRENCIES:
943 raise ValidationError(
944 f'convert_currency: {_("Invalid currency code")}: {currency}'
945 )
947 try:
948 converted = convert_money(money, currency)
949 except MissingRate:
950 # Re-throw error with more context
951 raise ValidationError(
952 f'convert_currency: {_("Missing exchange rate")} {money.currency} -> {currency}'
953 )
955 return converted
958@register.simple_tag
959def render_html_text(text: str, **kwargs):
960 """Render a text item with some simple html tags.
962 kwargs:
963 bold: Boolean, whether bold (or not)
964 italic: Boolean, whether italic (or not)
965 heading: str, heading level e.g. 'h3'
966 """
967 tags = []
969 if kwargs.get('bold'):
970 tags.append('strong')
972 if kwargs.get('italic'):
973 tags.append('em')
975 if heading := kwargs.get('heading', ''):
976 tags.append(heading)
978 output = ''.join([f'<{tag}>' for tag in tags])
979 output += text
980 output += ''.join([f'</{tag}>' for tag in tags])
982 return mark_safe(output)
985@register.simple_tag
986def format_number(
987 number: int | float | Decimal,
988 multiplier: Optional[int | float | Decimal] = None,
989 integer: bool = False,
990 separator: bool = False,
991 leading: Optional[int] = None,
992 decimal_places: Optional[int] = None,
993 max_decimal_places: Optional[int] = None,
994 fmt: Optional[str] = None,
995 locale: Optional[str] = None,
996 **kwargs,
997) -> str:
998 """Render a number with optional formatting options.
1000 Arguments:
1001 number: The number to be formatted
1002 multiplier: Optional multiplier to apply to the number before formatting
1003 integer: Boolean, whether to render the number as an integer
1004 separator: Boolean, whether to include a thousands separator
1005 leading: Minimum number of leading digits to render (default = 1)
1006 decimal_places: Number of decimal places to render (default = 0)
1007 max_decimal_places: Maximum number of decimal places to render (default = 0)
1008 separator:
1009 fmt: Optional format string for the number - if provided, takes priority over 'decimal_places' and 'leading'
1010 locale: Optional locale override (e.g. 'en-us', 'de-de'). When set, babel controls decimal and thousands separators.
1011 """
1012 check_nulls('format_number', number)
1014 # Check that the provided number is valid
1015 try:
1016 number = Decimal(str(number).strip())
1017 except Exception:
1018 # If the number cannot be converted to a Decimal, just return the original value
1019 return str(number)
1021 number = float(number)
1023 if multiplier is not None:
1024 number *= float(multiplier)
1026 if integer:
1027 number = int(number)
1029 # Construct a formatting string for the number, based on the provided options
1030 if not fmt:
1031 fmt = '###,###,###,###,##0' # Default format string - this will be modified based on the provided options
1033 # The 'leading' option specifies the minimum number of leading digits to render (not including decimal places)
1034 if leading is not None:
1035 try:
1036 leading = int(leading) or 0
1037 except (ValueError, TypeError):
1038 leading = 0
1040 if leading > 1:
1041 fmt = fmt[::-1].replace('#', '0', (leading - 1))[::-1]
1043 if not bool(separator):
1044 fmt = fmt.replace(',', '')
1046 if decimal_places is not None or max_decimal_places is not None:
1047 # Account for decimal places, if provided
1049 try:
1050 decimal_places = int(decimal_places) or 0
1051 except (ValueError, TypeError):
1052 decimal_places = 0
1054 try:
1055 max_decimal_places = int(max_decimal_places) or 0
1056 except (ValueError, TypeError):
1057 max_decimal_places = 0
1059 fmt += '.' + '0' * decimal_places
1061 if max_decimal_places > decimal_places:
1062 fmt += '#' * (max_decimal_places - decimal_places)
1063 elif not integer:
1064 # No decimal places specified, allow any number of decimal places (up to the precision of the Decimal)
1065 fmt += '.####################'
1067 babel_locale = get_locale(locale)
1069 return babel_format_decimal(
1070 number, format=fmt, locale=babel_locale, numbering_system='latn'
1071 )
1074@register.simple_tag
1075def format_datetime(
1076 dt: datetime,
1077 timezone: Optional[str] = None,
1078 fmt: Optional[str] = None,
1079 locale: Optional[str] = None,
1080 date_format: str = 'medium',
1081 **kwargs,
1082):
1083 """Format a datetime object for display.
1085 Arguments:
1086 dt: The datetime object to format
1087 timezone: The timezone to use for the date (defaults to the server timezone)
1088 fmt: The strftime format string to use. When provided, takes priority over locale and date_format.
1089 locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given.
1090 date_format: Babel date format style. One of 'full', 'long', 'medium' (default), 'short'.
1091 """
1092 check_nulls('format_datetime', dt)
1094 dt = InvenTree.helpers.to_local_time(dt, timezone)
1096 if fmt:
1097 return dt.strftime(fmt)
1099 return babel_format_datetime(dt, format=date_format, locale=get_locale(locale))
1102@register.simple_tag
1103def format_date(
1104 dt: date,
1105 timezone: Optional[str] = None,
1106 fmt: Optional[str] = None,
1107 locale: Optional[str] = None,
1108 date_format: str = 'medium',
1109 **kwargs,
1110):
1111 """Format a date object for display.
1113 Arguments:
1114 dt: The date to format
1115 timezone: The timezone to use for the date (defaults to the server timezone)
1116 fmt: The strftime format string to use. When provided, takes priority over locale and date_format.
1117 locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given.
1118 date_format: Babel date format style. One of 'full', 'long', 'medium' (default), 'short'.
1119 """
1120 check_nulls('format_date', dt)
1122 try:
1123 dt = InvenTree.helpers.to_local_time(dt, timezone).date()
1124 except TypeError:
1125 return str(dt)
1127 if fmt:
1128 return dt.strftime(fmt)
1130 return babel_format_date(dt, format=date_format, locale=get_locale(locale))
1133@register.simple_tag()
1134def icon(name, **kwargs):
1135 """Render an icon from the icon packs.
1137 Arguments:
1138 name: The name of the icon to render
1140 Keyword Arguments:
1141 class: Optional class name(s) to apply to the icon element
1142 """
1143 if not name:
1144 return ''
1146 try:
1147 pack, icon, variant = common.icons.validate_icon(name)
1148 except ValidationError:
1149 return ''
1151 unicode = chr(int(icon['variants'][variant], 16))
1152 return mark_safe(
1153 f'<i class="icon {kwargs.get("class", "")}" style="font-family: inventree-icon-font-{pack.prefix}">{unicode}</i>'
1154 )
1157@register.simple_tag()
1158def include_icon_fonts(ttf: bool = False, woff: bool = False):
1159 """Return the CSS font-face rule for the icon fonts used on the current page (or all)."""
1160 fonts = []
1162 if not ttf and not woff:
1163 ttf = woff = True
1165 for font in common.icons.get_icon_packs().values():
1166 # generate the font src string (prefer ttf over woff, woff2 is not supported by weasyprint)
1167 if 'truetype' in font.fonts and ttf:
1168 font_format, url = 'truetype', font.fonts['truetype']
1169 elif 'woff' in font.fonts and woff:
1170 font_format, url = 'woff', font.fonts['woff']
1172 fonts.append(f"""
1173@font-face {'{'}
1174 font-family: 'inventree-icon-font-{font.prefix}';
1175 src: url('{InvenTree.helpers_model.construct_absolute_url(url)}') format('{font_format}');
1176{'}'}\n""")
1178 icon_class = f"""
1179.icon {'{'}
1180 font-style: normal;
1181 font-weight: normal;
1182 font-variant: normal;
1183 text-transform: none;
1184 line-height: 1;
1185 /* Better font rendering */
1186 -webkit-font-smoothing: antialiased;
1187 -moz-osx-font-smoothing: grayscale;
1188{'}'}
1189 """
1191 return mark_safe(icon_class + '\n'.join(fonts))
1194@register.simple_tag()
1195def lowercase(value: str) -> str:
1196 """Convert a string to lowercase.
1198 Arguments:
1199 value: The string to be converted
1200 """
1201 if not value:
1202 return ''
1203 return str(value).lower()
1206@register.simple_tag()
1207def uppercase(value: str) -> str:
1208 """Convert a string to uppercase.
1210 Arguments:
1211 value: The string to be converted
1212 """
1213 if not value:
1214 return ''
1215 return str(value).upper()
1218@register.simple_tag()
1219def titlecase(value: str) -> str:
1220 """Convert a string to title case.
1222 Arguments:
1223 value: The string to be converted
1224 """
1225 if not value:
1226 return ''
1227 return str(value).title()
1230@register.simple_tag()
1231def strip(value: str, chars: Optional[str] = ' ') -> str:
1232 """Strip leading and trailing characters from a string.
1234 Arguments:
1235 value: The string to be stripped
1236 chars: The set of characters to strip from the string (default = whitespace)
1237 """
1238 if not value:
1239 return ''
1240 return str(value).strip(chars)
1243@register.simple_tag()
1244def lstrip(value: str, chars: Optional[str] = ' ') -> str:
1245 """Strip leading characters from a string.
1247 Arguments:
1248 value: The string to be stripped
1249 chars: The set of characters to strip from the string (default = whitespace)
1250 """
1251 if not value:
1252 return ''
1253 return str(value).lstrip(chars)
1256@register.simple_tag()
1257def rstrip(value: str, chars: Optional[str] = ' ') -> str:
1258 """Strip trailing characters from a string.
1260 Arguments:
1261 value: The string to be stripped
1262 chars: The set of characters to strip from the string (default = whitespace)
1263 """
1264 if not value:
1265 return ''
1266 return str(value).rstrip(chars)
1269@register.simple_tag()
1270def split(value: str, separator: str = ',') -> list:
1271 """Split a string into a list, using the provided separator (default = ',').
1273 Arguments:
1274 value: The string to be split
1275 separator: The character to use as a separator (default = ',')
1276 """
1277 if not value:
1278 return []
1279 return [v.strip() for v in str(value).split(separator)]
1282@register.simple_tag()
1283def join(value: list, separator: str = ',') -> str:
1284 """Join a list of items into a string, using the provided separator (default = ',').
1286 Arguments:
1287 value: The list of items to be joined
1288 separator: The character to use as a separator (default = ',')
1289 """
1290 if not value:
1291 return ''
1292 return separator.join(str(v) for v in value)
1295@register.simple_tag()
1296def length(value: Any) -> int:
1297 """Return the length of a list or string.
1299 Arguments:
1300 value: The value to be measured (e.g. a list or string)
1301 """
1302 if value is None:
1303 return 0
1304 try:
1305 return len(value)
1306 except TypeError:
1307 return 0
1310@register.simple_tag()
1311def replace(value: str, old: str, new: str = '') -> str:
1312 """Replace occurrences of a substring within a string with a new value.
1314 Arguments:
1315 value: The original string
1316 old: The substring to be replaced
1317 new: The value to replace the old substring with (default = "")
1318 """
1319 if not value:
1320 return ''
1321 return str(value).replace(old, new)
1324@register.simple_tag()
1325def first(value: list, default: Any = None) -> Any:
1326 """Return the first item in a list, or a default value if the list is empty.
1328 Arguments:
1329 value: The list from which to retrieve the first item
1330 default: The value to return if the list is empty (default = None)
1331 """
1332 if not value:
1333 return default
1334 try:
1335 return value[0]
1336 except (IndexError, TypeError):
1337 return default
1340@register.simple_tag()
1341def last(value: list, default: Any = None) -> Any:
1342 """Return the last item in a list, or a default value if the list is empty.
1344 Arguments:
1345 value: The list from which to retrieve the last item
1346 default: The value to return if the list is empty (default = None)
1347 """
1348 if not value:
1349 return default
1350 try:
1351 return value[-1]
1352 except (IndexError, TypeError):
1353 return default
1356@register.simple_tag()
1357def reverse(value: list) -> list:
1358 """Return a reversed version of the provided list.
1360 Arguments:
1361 value: The list to be reversed
1362 """
1363 if not value:
1364 return []
1365 try:
1366 return value[::-1]
1367 except TypeError:
1368 return []
1371@register.simple_tag()
1372def truncate(value: list, length: int) -> list:
1373 """Return a truncated version of the provided list.
1375 Arguments:
1376 value: The list to be truncated
1377 length: The maximum length of the returned list
1378 """
1379 if not value:
1380 return []
1381 try:
1382 return value[:length]
1383 except TypeError:
1384 return []