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

1"""Custom template tags for report generation.""" 

2 

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 

12 

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 _ 

24 

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 

35 

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 

45 

46register = template.Library() 

47 

48 

49logger = logging.getLogger('inventree') 

50 

51 

52def get_locale(locale: Optional[str] = None) -> Locale: 

53 """Resolve and return a babel Locale. 

54 

55 Args: 

56 locale: Optional locale string (e.g. 'en-us'). Falls back to LANGUAGE_CODE. 

57 

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}") 

66 

67 

68@register.simple_tag() 

69def order_queryset(queryset: QuerySet, *args) -> QuerySet: 

70 """Order a database queryset based on the provided arguments. 

71 

72 Arguments: 

73 queryset: The queryset to order 

74 

75 Keyword Arguments: 

76 field (str): Order the queryset based on the provided field 

77 

78 Example: 

79 {% order_queryset companies 'name' as ordered_companies %} 

80 """ 

81 if not isinstance(queryset, QuerySet): 

82 return queryset 

83 

84 return queryset.order_by(*args) 

85 

86 

87@register.simple_tag() 

88def filter_queryset(queryset: QuerySet, **kwargs) -> QuerySet: 

89 """Filter a database queryset based on the provided keyword arguments. 

90 

91 Arguments: 

92 queryset: The queryset to filter 

93 

94 Keyword Arguments: 

95 field (any): Filter the queryset based on the provided field 

96 

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) 

103 

104 

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. 

108 

109 Arguments: 

110 model_name: The name of the Django model - including app name (e.g. 'part.partcategory') 

111 

112 Keyword Arguments: 

113 field (any): Filter the queryset based on the provided field 

114 

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 

122 

123 try: 

124 model = apps.get_model(app_name, model_name) 

125 except Exception: 

126 return None 

127 

128 if model is None: 

129 return None 

130 

131 queryset = model.objects.all() 

132 

133 return filter_queryset(queryset, **kwargs) 

134 

135 

136@register.simple_tag() 

137def getindex(container: list, index: int) -> Any: 

138 """Return the value contained at the specified index of the list. 

139 

140 This function is provided to get around template rendering limitations. 

141 

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 

151 

152 if index < 0 or index >= len(container): 

153 return None 

154 return container[index] 

155 

156 

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. 

160 

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 

163 

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 

172 

173 return container.get(key, backup_value) 

174 

175 

176def media_file_exists(path: Path | str) -> bool: 

177 """Check if a media file exists at the specified path. 

178 

179 Arguments: 

180 path: The path to the media file, relative to the media storage root 

181 

182 Returns: 

183 True if the file exists, False otherwise 

184 """ 

185 if not path: 

186 return False 

187 

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}'") 

193 

194 

195def static_file_exists(path: Path | str) -> bool: 

196 """Check if a static file exists at the specified path. 

197 

198 Arguments: 

199 path: The path to the static file, relative to the static storage root 

200 

201 Returns: 

202 True if the file exists, False otherwise 

203 """ 

204 if not path: 

205 return False 

206 

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}'") 

212 

213 

214def get_static_file_contents( 

215 path: Path | str, raise_error: bool = False 

216) -> bytes | None: 

217 """Return the contents of a static file. 

218 

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) 

222 

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 

231 

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 

237 

238 with staticfiles_storage.open(str(path)) as f: 

239 file_data = f.read() 

240 

241 return file_data 

242 

243 

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. 

248 

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) 

252 

253 Returns: 

254 The contents of the media file, or None if the file cannot be found 

255 

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 

260 

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 

269 

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 

275 

276 # Load the file - and return the contents 

277 with default_storage.open(str(path)) as f: 

278 file_data = f.read() 

279 

280 return file_data 

281 

282 

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. 

286 

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) 

290 

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 

301 

302 if type(filename) is SafeString: 

303 # Prepend an empty string to enforce 'stringiness' 

304 filename = '' + filename 

305 

306 # Remove any leading slash characters from the filename, to prevent path traversal attacks 

307 filename = str(filename).lstrip('/\\') 

308 

309 full_path = Path('report', 'assets', filename) 

310 

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 

316 

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)) 

320 

321 file_data = get_media_file_contents(full_path, raise_error=raise_error) 

322 

323 if not file_data: 

324 return None 

325 

326 mime_type, _encoding = mimetypes.guess_type(str(filename)) 

327 if not mime_type: 

328 mime_type = 'application/octet-stream' 

329 

330 encoded = base64.b64encode(file_data).decode('ascii') 

331 return f'data:{mime_type};base64,{encoded}' 

332 

333 

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. 

347 

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) 

357 

358 Returns: 

359 Binary image data to be rendered directly in a <img> tag 

360 

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 

368 

369 # Strip out any leading slash characters from the filename, to prevent path traversal attacks 

370 filename = str(filename).lstrip('/\\') 

371 

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) 

374 

375 # Load image data - this will check if the file exists 

376 exists = bool(filename) and media_file_exists(filename) 

377 

378 if not exists and not replace_missing: 

379 raise FileNotFoundError(_('Image file not found') + f": '{filename}'") 

380 

381 if exists: 

382 img_data = get_media_file_contents(filename, raise_error=raise_error) 

383 

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 ) 

399 

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) 

404 

405 return staticfiles_storage.url(str(Path('img', replacement_file))) 

406 

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') 

412 

413 if width is not None: 

414 try: 

415 width = int(width) 

416 except ValueError: 

417 width = None 

418 

419 if height is not None: 

420 try: 

421 height = int(height) 

422 except ValueError: 

423 height = None 

424 

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)) 

438 

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 

446 

447 # Return a base-64 encoded image 

448 img_data = report.helpers.encode_image_base64(img) 

449 

450 return img_data 

451 

452 

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 

459 

460 # Remove any leading slash characters from the filename, to prevent path traversal attacks 

461 filename = str(filename).lstrip('/\\') 

462 

463 if not filename: 

464 raise FileNotFoundError(_('No image file specified')) 

465 

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) 

469 

470 # Return the base64-encoded data 

471 return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode( 

472 'utf-8' 

473 ) 

474 

475 

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. 

479 

480 Arguments: 

481 part: A Part model instance 

482 preview: Return the preview image (default = False) 

483 thumbnail: Return the thumbnail image (default = False) 

484 

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')) 

490 

491 image_filename = InvenTree.helpers.image2name(part.image, preview, thumbnail) 

492 

493 if kwargs.get('check_exists'): 

494 if not media_file_exists(image_filename): 

495 raise FileNotFoundError(_('Image file not found') + f": '{image_filename}'") 

496 

497 return uploaded_image( 

498 InvenTree.helpers.image2name(part.image, preview, thumbnail), **kwargs 

499 ) 

500 

501 

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. 

507 

508 Arguments: 

509 instance: A Model object 

510 parameter_name: The name of the parameter to retrieve (case insensitive) 

511 

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') 

517 

518 if not hasattr(instance, 'parameters'): 

519 raise ValidationError( 

520 "parameter tag requires a Model with 'parameters' attribute" 

521 ) 

522 

523 parameters = instance.parameters_list.all().prefetch_related('template') 

524 

525 # First try with exact match 

526 if parameter := parameters.filter(template__name=parameter_name).first(): 

527 return parameter 

528 

529 # Next, try with case-insensitive match 

530 if parameter := parameters.filter(template__name__iexact=parameter_name).first(): 

531 return parameter 

532 

533 return None 

534 

535 

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. 

541 

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 

546 

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 

552 

553 # If the matching parameter is not found, return the backup value 

554 return backup_value 

555 

556 

557@register.simple_tag() 

558def part_parameter(instance, parameter_name): 

559 """Included for backwards compatibility - use 'parameter' tag instead. 

560 

561 Ref: https://github.com/inventree/InvenTree/pull/10699 

562 """ 

563 return parameter(instance, parameter_name) 

564 

565 

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. 

571 

572 Arguments: 

573 company: A Company model instance 

574 preview: Return the preview image (default = False) 

575 thumbnail: Return the thumbnail image (default = False) 

576 

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 ) 

585 

586 

587@register.simple_tag() 

588def logo_image(**kwargs) -> str: 

589 """Return a fully-qualified path for the logo image. 

590 

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) 

596 

597 return InvenTree.helpers.getLogoImage(as_file=not debug_mode, **kwargs) 

598 

599 

600@register.simple_tag() 

601def internal_link(link, text) -> str: 

602 """Make a <a></a> href which points to an InvenTree URL. 

603 

604 Uses the InvenTree.helpers_model.construct_absolute_url function to build the URL. 

605 """ 

606 text = str(text) 

607 

608 try: 

609 url = InvenTree.helpers_model.construct_absolute_url(link) 

610 except Exception: 

611 url = None 

612 

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 

617 

618 return mark_safe(f'<a href="{url}">{text}</a>') 

619 

620 

621def make_decimal(value: Any) -> Any: 

622 """Convert an input value into a Decimal. 

623 

624 - Converts [string, int, float] types into Decimal 

625 - If conversion fails, returns the original value 

626 

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 ) 

639 

640 return value 

641 

642 

643def cast_to_type(value: Any, cast: type) -> Any: 

644 """Attempt to cast a value to the provided type. 

645 

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 

653 

654 return value 

655 

656 

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__})" 

660 

661 

662def check_nulls(func: str, *arg): 

663 """Check if any of the provided arguments is null. 

664 

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")}') 

670 

671 

672@register.simple_tag() 

673def add(x: Any, y: Any, cast: Optional[type] = None) -> Any: 

674 """Add two numbers (or number like values) together. 

675 

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) 

680 

681 Raises: 

682 ValidationError: If the values cannot be added together 

683 """ 

684 check_nulls('add', x, y) 

685 

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) 

693 

694 

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. 

698 

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) 

703 

704 Raises: 

705 ValidationError: If the values cannot be subtracted 

706 """ 

707 check_nulls('subtract', x, y) 

708 

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 ) 

715 

716 return cast_to_type(result, cast) 

717 

718 

719@register.simple_tag() 

720def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any: 

721 """Multiply two numbers (or number-like values) together. 

722 

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) 

727 

728 Raises: 

729 ValidationError: If the values cannot be multiplied together 

730 """ 

731 check_nulls('multiply', x, y) 

732 

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 ) 

739 

740 return cast_to_type(result, cast) 

741 

742 

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. 

746 

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) 

751 

752 Raises: 

753 ValidationError: If the values cannot be divided 

754 """ 

755 check_nulls('divide', x, y) 

756 

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 ) 

767 

768 return cast_to_type(result, cast) 

769 

770 

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. 

774 

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) 

779 

780 Raises: 

781 ValidationError: If the values cannot be used in a modulo operation 

782 """ 

783 check_nulls('modulo', x, y) 

784 

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 ) 

795 

796 return cast_to_type(result, cast) 

797 

798 

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. 

813 

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

827 

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

837 

838 if currency is not None: 

839 try: 

840 money = convert_money(money, currency) 

841 except Exception: 

842 pass 

843 

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 ) 

851 

852 locale = get_locale(locale) 

853 

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 ) 

864 

865 pattern = copy.copy(locale.currency_formats['standard']) 

866 

867 if decimal_places is None or not isinstance(decimal_places, (int, float)): 

868 decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0) 

869 

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) 

872 

873 pattern.frac_prec = (decimal_places, max(decimal_places, max_decimal_places)) 

874 

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)) 

883 

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 ) 

892 

893 

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. 

899 

900 Arguments: 

901 amount: The numeric amount (a numeric type or string) 

902 currency: The currency code (e.g. 'USD', 'EUR', etc.) 

903 

904 Note: If the currency is not provided, the default system currency will be used. 

905 """ 

906 check_nulls('create_currency', amount) 

907 

908 currency = currency or common.currency.currency_code_default() 

909 currency = currency.strip().upper() 

910 

911 if currency not in common.currency.CURRENCIES: 

912 raise ValidationError( 

913 f'create_currency: {_("Invalid currency code")}: {currency}' 

914 ) 

915 

916 try: 

917 money = Money(amount, currency) 

918 except InvalidOperation: 

919 raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}') 

920 

921 return money 

922 

923 

924@register.simple_tag 

925def convert_currency(money: Money, currency: Optional[str] = None, **kwargs): 

926 """Convert a Money object to the specified currency. 

927 

928 Arguments: 

929 money: The Money instance to be converted 

930 currency: The target currency code (e.g. 'USD', 'EUR', etc.) 

931 

932 Note: If the currency is not provided, the default system currency will be used. 

933 """ 

934 check_nulls('convert_currency', money) 

935 

936 if not isinstance(money, Money): 

937 raise TypeError('convert_currency tag requires a Money instance') 

938 

939 currency = currency or common.currency.currency_code_default() 

940 currency = currency.strip().upper() 

941 

942 if currency not in common.currency.CURRENCIES: 

943 raise ValidationError( 

944 f'convert_currency: {_("Invalid currency code")}: {currency}' 

945 ) 

946 

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 ) 

954 

955 return converted 

956 

957 

958@register.simple_tag 

959def render_html_text(text: str, **kwargs): 

960 """Render a text item with some simple html tags. 

961 

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 = [] 

968 

969 if kwargs.get('bold'): 

970 tags.append('strong') 

971 

972 if kwargs.get('italic'): 

973 tags.append('em') 

974 

975 if heading := kwargs.get('heading', ''): 

976 tags.append(heading) 

977 

978 output = ''.join([f'<{tag}>' for tag in tags]) 

979 output += text 

980 output += ''.join([f'</{tag}>' for tag in tags]) 

981 

982 return mark_safe(output) 

983 

984 

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. 

999 

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) 

1013 

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) 

1020 

1021 number = float(number) 

1022 

1023 if multiplier is not None: 

1024 number *= float(multiplier) 

1025 

1026 if integer: 

1027 number = int(number) 

1028 

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 

1032 

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 

1039 

1040 if leading > 1: 

1041 fmt = fmt[::-1].replace('#', '0', (leading - 1))[::-1] 

1042 

1043 if not bool(separator): 

1044 fmt = fmt.replace(',', '') 

1045 

1046 if decimal_places is not None or max_decimal_places is not None: 

1047 # Account for decimal places, if provided 

1048 

1049 try: 

1050 decimal_places = int(decimal_places) or 0 

1051 except (ValueError, TypeError): 

1052 decimal_places = 0 

1053 

1054 try: 

1055 max_decimal_places = int(max_decimal_places) or 0 

1056 except (ValueError, TypeError): 

1057 max_decimal_places = 0 

1058 

1059 fmt += '.' + '0' * decimal_places 

1060 

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 += '.####################' 

1066 

1067 babel_locale = get_locale(locale) 

1068 

1069 return babel_format_decimal( 

1070 number, format=fmt, locale=babel_locale, numbering_system='latn' 

1071 ) 

1072 

1073 

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. 

1084 

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) 

1093 

1094 dt = InvenTree.helpers.to_local_time(dt, timezone) 

1095 

1096 if fmt: 

1097 return dt.strftime(fmt) 

1098 

1099 return babel_format_datetime(dt, format=date_format, locale=get_locale(locale)) 

1100 

1101 

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. 

1112 

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) 

1121 

1122 try: 

1123 dt = InvenTree.helpers.to_local_time(dt, timezone).date() 

1124 except TypeError: 

1125 return str(dt) 

1126 

1127 if fmt: 

1128 return dt.strftime(fmt) 

1129 

1130 return babel_format_date(dt, format=date_format, locale=get_locale(locale)) 

1131 

1132 

1133@register.simple_tag() 

1134def icon(name, **kwargs): 

1135 """Render an icon from the icon packs. 

1136 

1137 Arguments: 

1138 name: The name of the icon to render 

1139 

1140 Keyword Arguments: 

1141 class: Optional class name(s) to apply to the icon element 

1142 """ 

1143 if not name: 

1144 return '' 

1145 

1146 try: 

1147 pack, icon, variant = common.icons.validate_icon(name) 

1148 except ValidationError: 

1149 return '' 

1150 

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 ) 

1155 

1156 

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 = [] 

1161 

1162 if not ttf and not woff: 

1163 ttf = woff = True 

1164 

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

1171 

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""") 

1177 

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 """ 

1190 

1191 return mark_safe(icon_class + '\n'.join(fonts)) 

1192 

1193 

1194@register.simple_tag() 

1195def lowercase(value: str) -> str: 

1196 """Convert a string to lowercase. 

1197 

1198 Arguments: 

1199 value: The string to be converted 

1200 """ 

1201 if not value: 

1202 return '' 

1203 return str(value).lower() 

1204 

1205 

1206@register.simple_tag() 

1207def uppercase(value: str) -> str: 

1208 """Convert a string to uppercase. 

1209 

1210 Arguments: 

1211 value: The string to be converted 

1212 """ 

1213 if not value: 

1214 return '' 

1215 return str(value).upper() 

1216 

1217 

1218@register.simple_tag() 

1219def titlecase(value: str) -> str: 

1220 """Convert a string to title case. 

1221 

1222 Arguments: 

1223 value: The string to be converted 

1224 """ 

1225 if not value: 

1226 return '' 

1227 return str(value).title() 

1228 

1229 

1230@register.simple_tag() 

1231def strip(value: str, chars: Optional[str] = ' ') -> str: 

1232 """Strip leading and trailing characters from a string. 

1233 

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) 

1241 

1242 

1243@register.simple_tag() 

1244def lstrip(value: str, chars: Optional[str] = ' ') -> str: 

1245 """Strip leading characters from a string. 

1246 

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) 

1254 

1255 

1256@register.simple_tag() 

1257def rstrip(value: str, chars: Optional[str] = ' ') -> str: 

1258 """Strip trailing characters from a string. 

1259 

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) 

1267 

1268 

1269@register.simple_tag() 

1270def split(value: str, separator: str = ',') -> list: 

1271 """Split a string into a list, using the provided separator (default = ','). 

1272 

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)] 

1280 

1281 

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 = ','). 

1285 

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) 

1293 

1294 

1295@register.simple_tag() 

1296def length(value: Any) -> int: 

1297 """Return the length of a list or string. 

1298 

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 

1308 

1309 

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. 

1313 

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) 

1322 

1323 

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. 

1327 

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 

1338 

1339 

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. 

1343 

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 

1354 

1355 

1356@register.simple_tag() 

1357def reverse(value: list) -> list: 

1358 """Return a reversed version of the provided list. 

1359 

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 [] 

1369 

1370 

1371@register.simple_tag() 

1372def truncate(value: list, length: int) -> list: 

1373 """Return a truncated version of the provided list. 

1374 

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 []