Coverage for src/backend/InvenTree/report/models.py: 36%

347 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Report template model definitions.""" 

2 

3import io 

4import os 

5import sys 

6from datetime import date, datetime 

7from typing import Optional, TypedDict, cast 

8 

9from django.conf import settings 

10from django.contrib.auth.models import AbstractUser 

11from django.core.exceptions import ValidationError 

12from django.core.files.base import ContentFile 

13from django.core.files.storage import default_storage 

14from django.core.validators import FileExtensionValidator, MinValueValidator 

15from django.db import models 

16from django.template import Context, Template 

17from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError 

18from django.template.loader import render_to_string 

19from django.urls import reverse 

20from django.utils.translation import gettext_lazy as _ 

21 

22import structlog 

23from pypdf import PdfWriter 

24 

25import InvenTree.exceptions 

26import InvenTree.helpers 

27import InvenTree.models 

28import InvenTree.ready 

29import report.helpers 

30import report.validators 

31from common.models import DataOutput, RenderChoices, UpdatedUserMixin 

32from common.settings import get_global_setting 

33from InvenTree.helpers_model import get_base_url 

34from InvenTree.models import MetadataMixin 

35from plugin import InvenTreePlugin, PluginMixinEnum 

36from plugin.registry import registry 

37 

38try: 

39 from weasyprint import HTML 

40 

41 from report.fetcher import InvenTreeURLFetcher 

42except OSError as err: # pragma: no cover 

43 print(f'OSError: {err}') 

44 print("Unable to import 'weasyprint' module.") 

45 print('You may require some further system packages to be installed.') 

46 sys.exit(1) 

47 

48 

49logger = structlog.getLogger('inventree') 

50 

51 

52def log_report_error(*args, **kwargs): 

53 """Log an error message when a report fails to render.""" 

54 try: 

55 do_log = get_global_setting('REPORT_LOG_ERRORS', backup_value=True) 

56 except Exception: 

57 do_log = True 

58 

59 if do_log: 

60 InvenTree.exceptions.log_error(*args, **kwargs) 

61 

62 

63def rename_template(instance, filename): 

64 """Function to rename a report template once uploaded. 

65 

66 - Retains the original uploaded filename 

67 - Checks for duplicate filenames across instance class 

68 """ 

69 path = instance.get_upload_path(filename) 

70 

71 # Throw error if any other model instances reference this path 

72 instance.check_existing_file(path, raise_error=True) 

73 

74 # Delete file with this name if it already exists 

75 if default_storage.exists(path): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 logger.info(f'Deleting existing template file: {path}') 

77 default_storage.delete(path) 

78 

79 return path 

80 

81 

82class TemplateUploadMixin: 

83 """Mixin class for providing template path management functions. 

84 

85 - Provides generic method for determining the upload path for a template 

86 - Provides generic method for checking for duplicate filenames 

87 

88 Classes which inherit this mixin can guarantee that uploaded templates are unique, 

89 and that the same filename will be retained when uploaded. 

90 """ 

91 

92 # Directory in which to store uploaded templates 

93 SUBDIR = '' 

94 

95 # Name of the template field 

96 TEMPLATE_FIELD = 'template' 

97 

98 def __str__(self) -> str: 

99 """String representation of a TemplateUploadMixin instance.""" 

100 return str(os.path.basename(self.template_name)) 

101 

102 @property 

103 def template_name(self): 

104 """Return the filename of the template associated with this model class.""" 

105 template = getattr(self, self.TEMPLATE_FIELD).name 

106 template = template.replace('/', os.path.sep) 

107 template = template.replace('\\', os.path.sep) 

108 

109 template = settings.MEDIA_ROOT.joinpath(template) 

110 

111 return str(template) 

112 

113 @property 

114 def extension(self): 

115 """Return the filename extension of the associated template file.""" 

116 return os.path.splitext(self.template.name)[1].lower() 

117 

118 def get_upload_path(self, filename): 

119 """Generate an upload path for the given filename.""" 

120 fn = os.path.basename(filename) 

121 return os.path.join('report', self.SUBDIR, fn) 

122 

123 def check_existing_file(self, path, raise_error=False): 

124 """Check if a file already exists with the given filename.""" 

125 filters = {self.TEMPLATE_FIELD: self.get_upload_path(path)} 

126 

127 exists = self.__class__.objects.filter(**filters).exclude(pk=self.pk).exists() 

128 

129 if exists and raise_error: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 raise ValidationError({ 

131 self.TEMPLATE_FIELD: _('Template file with this name already exists') 

132 }) 

133 

134 return exists 

135 

136 def validate_unique(self, exclude=None): 

137 """Validate that this template is unique.""" 

138 proposed_path = self.get_upload_path(self.template_name) 

139 self.check_existing_file(proposed_path, raise_error=True) 

140 return super().validate_unique(exclude) 

141 

142 

143class BaseContextExtension(TypedDict): 

144 """Base context extension. 

145 

146 Attributes: 

147 base_url: The base URL for the InvenTree instance 

148 date: Current date, represented as a Python datetime.date object 

149 datetime: Current datetime, represented as a Python datetime object 

150 template: The report template instance which is being rendered against 

151 template_description: Description of the report template 

152 template_name: Name of the report template 

153 template_revision: Revision of the report template 

154 user: User who is creating the report (if available) 

155 """ 

156 

157 base_url: str 

158 date: date 

159 datetime: datetime 

160 template: 'ReportTemplateBase' 

161 template_description: str 

162 template_name: str 

163 template_revision: int 

164 user: Optional[AbstractUser] 

165 

166 

167class LabelContextExtension(TypedDict): 

168 """Label report context extension. 

169 

170 Attributes: 

171 width: The width of the label (in mm) 

172 height: The height of the label (in mm) 

173 page_style: The CSS @page style for the label template. This is used to be inserted at the top of the style block for a given label 

174 """ 

175 

176 width: float 

177 height: float 

178 page_style: Optional[str] 

179 

180 

181class ReportContextExtension(TypedDict): 

182 """Report context extension. 

183 

184 Attributes: 

185 page_size: The page size of the report 

186 landscape: Boolean value, True if the report is in landscape mode 

187 merge: Boolean value, True if the a single report is generated against multiple items 

188 """ 

189 

190 page_size: str 

191 landscape: bool 

192 merge: bool 

193 

194 

195class ReportTemplateBase( 

196 MetadataMixin, UpdatedUserMixin, InvenTree.models.InvenTreeModel 

197): 

198 """Base class for reports, labels.""" 

199 

200 class ModelChoices(RenderChoices): 

201 """Model choices for report templates.""" 

202 

203 choice_fnc = report.helpers.report_model_options 

204 

205 class Meta: 

206 """Metaclass options.""" 

207 

208 abstract = True 

209 unique_together = ('name', 'model_type') 

210 

211 def save(self, *args, **kwargs): 

212 """Perform additional actions when the report is saved.""" 

213 if kwargs.pop('increment_revision', True): 

214 # Increment revision number 

215 self.revision += 1 

216 

217 super().save() 

218 

219 name = models.CharField( 

220 blank=False, 

221 max_length=100, 

222 verbose_name=_('Name'), 

223 help_text=_('Template name'), 

224 ) 

225 

226 description = models.CharField( 

227 max_length=250, 

228 verbose_name=_('Description'), 

229 help_text=_('Template description'), 

230 ) 

231 

232 revision = models.PositiveIntegerField( 

233 default=1, 

234 verbose_name=_('Revision'), 

235 help_text=_('Revision number (auto-increments)'), 

236 editable=False, 

237 ) 

238 

239 attach_to_model = models.BooleanField( 

240 default=False, 

241 verbose_name=_('Attach to Model on Print'), 

242 help_text=_( 

243 'Save report output as an attachment against linked model instance when printing' 

244 ), 

245 ) 

246 

247 def generate_filename(self, context, **kwargs) -> str: 

248 """Generate a filename for this report.""" 

249 template_string = Template(self.filename_pattern) 

250 return template_string.render(Context(context)) 

251 

252 def render_as_string( 

253 self, instance: models.Model, context: Optional[dict] = None, **kwargs 

254 ) -> str: 

255 """Render the report to a HTML string. 

256 

257 Arguments: 

258 instance: The model instance to render against 

259 context: Django template language contexts (optional) 

260 

261 Returns: 

262 str: HTML string 

263 """ 

264 if context is None: 

265 context = self.get_context(instance, **kwargs) 

266 

267 return render_to_string(self.template_name, context) 

268 

269 def render( 

270 self, instance: models.Model, context: Optional[dict] = None, **kwargs 

271 ) -> bytes: 

272 """Render the template to a PDF file. 

273 

274 Arguments: 

275 instance: The model instance to render against 

276 context: Django template language contexts (optional) 

277 user: The user to associate with the generated report 

278 

279 Returns: 

280 bytes: PDF data 

281 """ 

282 html = self.render_as_string(instance, context=context, **kwargs) 

283 pdf = HTML(string=html, url_fetcher=InvenTreeURLFetcher()).write_pdf( 

284 pdf_forms=True 

285 ) 

286 

287 return pdf 

288 

289 filename_pattern = models.CharField( 

290 default='output.pdf', 

291 verbose_name=_('Filename Pattern'), 

292 help_text=_('Pattern for generating filenames'), 

293 max_length=100, 

294 ) 

295 

296 enabled = models.BooleanField( 

297 default=True, verbose_name=_('Enabled'), help_text=_('Template is enabled') 

298 ) 

299 

300 model_type = models.CharField( 

301 max_length=100, 

302 validators=[report.validators.validate_report_model_type], 

303 verbose_name=_('Model Type'), 

304 help_text=_('Target model type for template'), 

305 ) 

306 

307 def clean(self): 

308 """Clean model instance, and ensure validity.""" 

309 super().clean() 

310 

311 model = self.get_model() 

312 filters = self.filters 

313 

314 if model and filters: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 report.validators.validate_filters(filters, model=model) 

316 

317 def get_model(self): 

318 """Return the database model class associated with this report template.""" 

319 return report.helpers.report_model_from_name(self.model_type) 

320 

321 filters = models.CharField( 

322 blank=True, 

323 max_length=250, 

324 verbose_name=_('Filters'), 

325 help_text=_('Template query filters (comma-separated list of key=value pairs)'), 

326 validators=[report.validators.validate_filters], 

327 ) 

328 

329 def get_filters(self): 

330 """Return a filter dict which can be applied to the target model.""" 

331 return report.validators.validate_filters(self.filters, model=self.get_model()) 

332 

333 def base_context(self, **kwargs) -> BaseContextExtension: 

334 """Return base context data (available to all templates).""" 

335 return { 

336 'base_url': get_base_url(), 

337 'date': InvenTree.helpers.current_date(), 

338 'datetime': InvenTree.helpers.current_time(), 

339 'template': self, 

340 'template_description': self.description, 

341 'template_name': self.name, 

342 'template_revision': self.revision, 

343 'user': kwargs.get('user'), 

344 } 

345 

346 def get_context(self, instance: models.Model, **kwargs): 

347 """Supply context data to the generic template for rendering. 

348 

349 Arguments: 

350 instance: The model instance we are printing against 

351 user: The user to associate with the generated report 

352 """ 

353 # Provide base context information to all templates 

354 base_context = self.base_context(**kwargs) 

355 

356 # Add in an context information provided by the model instance itself 

357 context = {**base_context, **instance.report_context()} 

358 

359 return context 

360 

361 

362class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): 

363 """Class representing the ReportTemplate database model.""" 

364 

365 SUBDIR = 'report' 

366 TEMPLATE_FIELD = 'template' 

367 

368 @staticmethod 

369 def get_api_url(): 

370 """Return the API endpoint for the ReportTemplate model.""" 

371 return reverse('api-report-template-list') 

372 

373 def __init__(self, *args, **kwargs): 

374 """Initialize the particular report instance.""" 

375 super().__init__(*args, **kwargs) 

376 

377 self._meta.get_field( 

378 'page_size' 

379 ).choices = report.helpers.report_page_size_options() 

380 

381 template = models.FileField( 

382 upload_to=rename_template, 

383 verbose_name=_('Template'), 

384 help_text=_('Template file'), 

385 validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], 

386 ) 

387 

388 page_size = models.CharField( 

389 max_length=20, 

390 default=report.helpers.report_page_size_default, 

391 verbose_name=_('Page Size'), 

392 help_text=_('Page size for PDF reports'), 

393 ) 

394 

395 landscape = models.BooleanField( 

396 default=False, 

397 verbose_name=_('Landscape'), 

398 help_text=_('Render report in landscape orientation'), 

399 ) 

400 

401 merge = models.BooleanField( 

402 default=False, 

403 verbose_name=_('Merge'), 

404 help_text=_('Render a single report against selected items'), 

405 ) 

406 

407 def get_report_size(self) -> str: 

408 """Return the printable page size for this report.""" 

409 try: 

410 page_size_default = cast( 

411 str, get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False) 

412 ) 

413 except Exception: 

414 page_size_default = 'A4' 

415 

416 page_size = self.page_size or page_size_default 

417 

418 if self.landscape: 

419 page_size = page_size + ' landscape' 

420 

421 return page_size 

422 

423 def get_report_context(self): 

424 """Return report template context.""" 

425 report_context: ReportContextExtension = { 

426 'page_size': self.get_report_size(), 

427 'landscape': self.landscape, 

428 'merge': self.merge, 

429 } 

430 

431 return report_context 

432 

433 def get_context(self, instance: models.Model, **kwargs): 

434 """Supply context data to the report template for rendering. 

435 

436 Arguments: 

437 instance: The model instance we are printing against 

438 """ 

439 base_context = super().get_context(instance, **kwargs) 

440 report_context: ReportContextExtension = self.get_report_context() 

441 

442 context = {**base_context, **report_context} 

443 

444 # Pass the context through to the plugin registry for any additional information 

445 context = self.get_plugin_context(instance, context, **kwargs) 

446 return context 

447 

448 def get_plugin_context(self, instance: models.Model, context: dict, **kwargs): 

449 """Get the context for the plugin. 

450 

451 Arguments: 

452 instance: The model instance we are printing against 

453 context: The context dictionary to add to 

454 user: The user to associate with the generated report 

455 """ 

456 user = kwargs.get('user') 

457 

458 for plugin in registry.with_mixin(PluginMixinEnum.REPORT): 

459 try: 

460 plugin.add_report_context(self, instance, user, context) 

461 except Exception: 

462 InvenTree.exceptions.log_error('add_report_context', plugin=plugin.slug) 

463 

464 return context 

465 

466 def handle_attachment(self, instance, report, report_name, user, debug_mode): 

467 """Attach the generated report to the model instance (if required).""" 

468 if self.attach_to_model and not debug_mode: 

469 instance.create_attachment( 

470 attachment=ContentFile(report, report_name), 

471 comment=_(f'Report generated from template {self.name}'), 

472 upload_user=user, 

473 ) 

474 

475 def notify_plugins(self, instance, report, user): 

476 """Provide generated report to any interested plugins. 

477 

478 Arguments: 

479 instance: The model instance we are printing against 

480 report: The generated report object 

481 user: The user to associate with the generated report 

482 """ 

483 report_plugins = registry.with_mixin(PluginMixinEnum.REPORT) 

484 

485 for plugin in report_plugins: 

486 try: 

487 plugin.report_callback(self, instance, report, user) 

488 except Exception: 

489 InvenTree.exceptions.log_error('report_callback', plugin=plugin.slug) 

490 

491 def print( 

492 self, 

493 items: list, 

494 output: Optional[DataOutput] = None, 

495 user: Optional[AbstractUser] = None, 

496 **kwargs, 

497 ) -> DataOutput: 

498 """Print reports for a list of items against this template. 

499 

500 Arguments: 

501 items: A list of items to print reports for (model instance) 

502 output: The DataOutput object to use 

503 user: The user to associate with the generated report 

504 

505 Returns: 

506 output: The DataOutput object representing the generated report(s) 

507 

508 Raises: 

509 ValidationError: If there is an error during report printing 

510 

511 Notes: 

512 Currently, all items are rendered separately into PDF files, 

513 and then combined into a single PDF file. 

514 

515 Further work is required to allow the following extended features: 

516 - Render a single PDF file with the collated items (optional per template) 

517 - Render a raw file (do not convert to PDF) - allows for other file types 

518 """ 

519 logger.info("Printing %s reports against template '%s'", len(items), self.name) 

520 

521 # Extract user information from the provided context 

522 user = user or getattr(output, 'user', None) 

523 

524 outputs = [] 

525 

526 debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) 

527 

528 # Start with a default report name 

529 report_name: Optional[str] = None 

530 

531 # If a DataOutput object is not provided, create a new one 

532 if not output: 

533 output = DataOutput.objects.create( 

534 total=len(items), 

535 user=user, 

536 progress=0, 

537 complete=False, 

538 output_type=DataOutput.DataOutputTypes.REPORT, 

539 template_name=self.name, 

540 output=None, 

541 ) 

542 

543 if output.progress != 0: 

544 output.progress = 0 

545 output.save() 

546 

547 try: 

548 if self.merge: 

549 base_context = super().base_context(user=user) 

550 report_context = self.get_report_context() 

551 item_contexts = [] 

552 for instance in items: 

553 instance_context = instance.report_context() 

554 instance_context = self.get_plugin_context( 

555 instance, instance_context, user=user 

556 ) 

557 item_contexts.append(instance_context) 

558 

559 contexts = { 

560 **base_context, 

561 **report_context, 

562 'instances': item_contexts, 

563 } 

564 

565 if report_name is None: 

566 report_name = self.generate_filename(contexts) 

567 

568 try: 

569 if debug_mode: 

570 report = self.render_as_string( 

571 instance, user=user, context=contexts 

572 ) 

573 else: 

574 report = self.render(instance, user=user, context=contexts) 

575 except TemplateDoesNotExist as e: 

576 t_name = str(e) or self.template 

577 msg = f'Template file {t_name} does not exist' 

578 output.mark_failure(error=msg) 

579 raise ValidationError(msg) 

580 except TemplateSyntaxError as e: 

581 msg = _('Template syntax error') 

582 output.mark_failure(str(e) or msg) 

583 raise ValidationError(f'{msg}: {e!s}') 

584 except ValidationError as e: 

585 output.mark_failure(','.join(e.messages)) 

586 raise e 

587 except Exception as e: 

588 msg = _('Error rendering report') 

589 output.mark_failure(msg) 

590 raise ValidationError(f'{msg}: {e!s}') 

591 

592 outputs.append(report) 

593 self.handle_attachment(instance, report, report_name, user, debug_mode) 

594 self.notify_plugins(instance, report, user) 

595 

596 # Update the progress of the report generation 

597 output.progress += 1 

598 output.save() 

599 else: 

600 for instance in items: 

601 context = self.get_context(instance, user=user) 

602 

603 if report_name is None: 

604 report_name = self.generate_filename(context) 

605 

606 # Render the report output 

607 try: 

608 if debug_mode: 

609 report = self.render_as_string( 

610 instance, user=user, context=context 

611 ) 

612 else: 

613 report = self.render(instance, user=user, context=None) 

614 except TemplateDoesNotExist as e: 

615 t_name = str(e) or self.template 

616 msg = f'Template file {t_name} does not exist' 

617 output.mark_failure(error=msg) 

618 raise ValidationError(msg) 

619 except TemplateSyntaxError as e: 

620 msg = _('Template syntax error') 

621 output.mark_failure(error=str(e) or msg) 

622 raise ValidationError(f'{msg}: {e!s}') 

623 except ValidationError as e: 

624 output.mark_failure(', '.join(e.messages)) 

625 raise e 

626 except Exception as e: 

627 msg = _('Error rendering report') 

628 output.mark_failure(error=msg) 

629 raise ValidationError(f'{msg}: {e!s}') 

630 

631 outputs.append(report) 

632 

633 self.handle_attachment( 

634 instance, report, report_name, user, debug_mode 

635 ) 

636 

637 self.notify_plugins(instance, report, user) 

638 

639 # Update the progress of the report generation 

640 output.progress += 1 

641 output.save() 

642 

643 except Exception as exc: 

644 # Something went wrong during the report generation process 

645 log_report_error('ReportTemplate.print') 

646 

647 # If the error occurred in a worker thread, we do not want to raise an error, 

648 # as this would cause the worker to retry the task indefinitely 

649 if InvenTree.ready.isInWorkerThread(): 

650 return 

651 

652 # Raise a ValidationError with the error message 

653 # This will be caught by the caller and displayed to the user 

654 raise ValidationError({ 

655 'error': _('Error generating report'), 

656 'detail': str(exc), 

657 }) 

658 

659 if not report_name: 

660 report_name = '' # pragma: no cover 

661 

662 if not report_name.endswith('.pdf'): 

663 report_name += '.pdf' 

664 

665 # Combine all the generated reports into a single PDF file 

666 if debug_mode: 

667 data = '\n'.join(outputs) 

668 report_name = report_name.replace('.pdf', '.html') 

669 else: 

670 # Merge the outputs back together into a single PDF file 

671 pdf_writer = PdfWriter() 

672 

673 try: 

674 for report in outputs: 

675 # Construct file object with raw PDF data 

676 report_file = io.BytesIO(report) 

677 pdf_writer.append(report_file) 

678 

679 # Generate raw output 

680 pdf_file = io.BytesIO() 

681 pdf_writer.write(pdf_file) 

682 data = pdf_file.getvalue() 

683 pdf_file.close() 

684 except Exception: 

685 log_report_error('ReportTemplate.print') 

686 msg = _('Error merging report outputs') 

687 output.mark_failure(error=msg) 

688 

689 # If the error occurred in a worker thread, we do not want to raise an error, 

690 # as this would cause the worker to retry the task indefinitely 

691 if InvenTree.ready.isInWorkerThread(): 

692 return 

693 

694 raise ValidationError(msg) 

695 

696 # Save the generated report to the database 

697 generated_file = ContentFile(data, report_name) 

698 

699 output.mark_complete(output=generated_file) 

700 

701 return output 

702 

703 

704class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): 

705 """Class representing the LabelTemplate database model.""" 

706 

707 SUBDIR = 'label' 

708 TEMPLATE_FIELD = 'template' 

709 

710 @staticmethod 

711 def get_api_url(): 

712 """Return the API endpoint for the LabelTemplate model.""" 

713 return reverse('api-label-template-list') 

714 

715 template = models.FileField( 

716 upload_to=rename_template, 

717 verbose_name=_('Template'), 

718 help_text=_('Template file'), 

719 validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], 

720 ) 

721 

722 width = models.FloatField( 

723 default=50, 

724 verbose_name=_('Width [mm]'), 

725 help_text=_('Label width, specified in mm'), 

726 validators=[MinValueValidator(2)], 

727 ) 

728 

729 height = models.FloatField( 

730 default=20, 

731 verbose_name=_('Height [mm]'), 

732 help_text=_('Label height, specified in mm'), 

733 validators=[MinValueValidator(2)], 

734 ) 

735 

736 def generate_page_style(self, **kwargs): 

737 """Generate @page style for the label template. 

738 

739 This is inserted at the top of the style block for a given label 

740 """ 

741 width = kwargs.get('width', self.width) 

742 height = kwargs.get('height', self.height) 

743 margin = kwargs.get('margin', 0) 

744 

745 return f""" 

746 @page {{ 

747 size: {width}mm {height}mm; 

748 margin: {margin}mm; 

749 }} 

750 """ 

751 

752 def get_context(self, instance: models.Model, *args, **kwargs): 

753 """Supply context data to the label template for rendering. 

754 

755 Arguments: 

756 instance: The model instance we are printing against 

757 """ 

758 user = kwargs.get('user') 

759 

760 base_context = super().get_context(instance, **kwargs) 

761 

762 label_context: LabelContextExtension = { 

763 'width': self.width, 

764 'height': self.height, 

765 'page_style': None, 

766 } 

767 

768 context = {**base_context, **label_context} 

769 

770 if kwargs.pop('insert_page_style', True): 

771 context['page_style'] = self.generate_page_style() 

772 

773 # Pass the context through to any registered plugins 

774 plugins = registry.with_mixin(PluginMixinEnum.REPORT) 

775 

776 for plugin in plugins: 

777 # Let each plugin add its own context data 

778 try: 

779 plugin.add_label_context(self, instance, user, context) 

780 except Exception: 

781 InvenTree.exceptions.log_error('add_label_context', plugin=plugin.slug) 

782 

783 return context 

784 

785 def print( 

786 self, 

787 items: list, 

788 plugin: InvenTreePlugin, 

789 output: Optional[DataOutput] = None, 

790 options: Optional[dict] = None, 

791 user: Optional[AbstractUser] = None, 

792 **kwargs, 

793 ) -> DataOutput: 

794 """Print labels for a list of items against this template. 

795 

796 Arguments: 

797 items: A list of items to print labels for (model instance) 

798 plugin: The plugin to use for label rendering 

799 output: The DataOutput object to use 

800 options: Additional options for the label printing plugin (optional) 

801 user: The user to associate with the generated labels 

802 

803 Returns: 

804 output: The DataOutput object representing the generated label(s) 

805 

806 Raises: 

807 ValidationError: If there is an error during label printing 

808 """ 

809 logger.info( 

810 f"Printing {len(items)} labels against template '{self.name}' using plugin '{plugin.slug}'" 

811 ) 

812 

813 user = user or getattr(output, 'user', None) 

814 

815 if not output: 

816 output = DataOutput.objects.create( 

817 user=user, 

818 total=len(items), 

819 progress=0, 

820 complete=False, 

821 output_type=DataOutput.DataOutputTypes.LABEL, 

822 template_name=self.name, 

823 plugin=plugin.slug, 

824 output=None, 

825 ) 

826 

827 if options is None: 

828 options = {} 

829 

830 try: 

831 if hasattr(plugin, 'before_printing'): 

832 plugin.before_printing() 

833 

834 plugin.print_labels( 

835 self, output, items, None, user=user, printing_options=options 

836 ) 

837 

838 if hasattr(plugin, 'after_printing'): 

839 plugin.after_printing() 

840 except ValidationError as e: 

841 output.delete() 

842 raise e 

843 except Exception as e: 

844 output.delete() 

845 InvenTree.exceptions.log_error('print_labels', plugin=plugin.slug) 

846 raise ValidationError([_('Error printing labels'), str(e)]) 

847 

848 output.refresh_from_db() 

849 

850 # Return the output object 

851 return output 

852 

853 

854class ReportSnippet(TemplateUploadMixin, models.Model): 

855 """Report template 'snippet' which can be used to make templates that can then be included in other reports. 

856 

857 Useful for 'common' template actions, sub-templates, etc 

858 """ 

859 

860 SUBDIR = 'snippets' 

861 TEMPLATE_FIELD = 'snippet' 

862 

863 snippet = models.FileField( 

864 upload_to=rename_template, 

865 verbose_name=_('Snippet'), 

866 help_text=_('Report snippet file'), 

867 validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], 

868 ) 

869 

870 description = models.CharField( 

871 max_length=250, 

872 verbose_name=_('Description'), 

873 help_text=_('Snippet file description'), 

874 ) 

875 

876 

877class ReportAsset(TemplateUploadMixin, models.Model): 

878 """Asset file for use in report templates. 

879 

880 For example, an image to use in a header file. 

881 Uploaded asset files appear in MEDIA_ROOT/report/assets, 

882 and can be loaded in a template using the {% report_asset <filename> %} tag. 

883 """ 

884 

885 SUBDIR = 'assets' 

886 TEMPLATE_FIELD = 'asset' 

887 

888 # Asset file 

889 asset = models.FileField( 

890 upload_to=rename_template, 

891 verbose_name=_('Asset'), 

892 help_text=_('Report asset file'), 

893 ) 

894 

895 # Asset description (user facing string, not used internally) 

896 description = models.CharField( 

897 max_length=250, 

898 verbose_name=_('Description'), 

899 help_text=_('Asset file description'), 

900 )