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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Report template model definitions."""
3import io
4import os
5import sys
6from datetime import date, datetime
7from typing import Optional, TypedDict, cast
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 _
22import structlog
23from pypdf import PdfWriter
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
38try:
39 from weasyprint import HTML
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)
49logger = structlog.getLogger('inventree')
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
59 if do_log:
60 InvenTree.exceptions.log_error(*args, **kwargs)
63def rename_template(instance, filename):
64 """Function to rename a report template once uploaded.
66 - Retains the original uploaded filename
67 - Checks for duplicate filenames across instance class
68 """
69 path = instance.get_upload_path(filename)
71 # Throw error if any other model instances reference this path
72 instance.check_existing_file(path, raise_error=True)
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)
79 return path
82class TemplateUploadMixin:
83 """Mixin class for providing template path management functions.
85 - Provides generic method for determining the upload path for a template
86 - Provides generic method for checking for duplicate filenames
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 """
92 # Directory in which to store uploaded templates
93 SUBDIR = ''
95 # Name of the template field
96 TEMPLATE_FIELD = 'template'
98 def __str__(self) -> str:
99 """String representation of a TemplateUploadMixin instance."""
100 return str(os.path.basename(self.template_name))
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)
109 template = settings.MEDIA_ROOT.joinpath(template)
111 return str(template)
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()
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)
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)}
127 exists = self.__class__.objects.filter(**filters).exclude(pk=self.pk).exists()
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 })
134 return exists
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)
143class BaseContextExtension(TypedDict):
144 """Base context extension.
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 """
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]
167class LabelContextExtension(TypedDict):
168 """Label report context extension.
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 """
176 width: float
177 height: float
178 page_style: Optional[str]
181class ReportContextExtension(TypedDict):
182 """Report context extension.
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 """
190 page_size: str
191 landscape: bool
192 merge: bool
195class ReportTemplateBase(
196 MetadataMixin, UpdatedUserMixin, InvenTree.models.InvenTreeModel
197):
198 """Base class for reports, labels."""
200 class ModelChoices(RenderChoices):
201 """Model choices for report templates."""
203 choice_fnc = report.helpers.report_model_options
205 class Meta:
206 """Metaclass options."""
208 abstract = True
209 unique_together = ('name', 'model_type')
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
217 super().save()
219 name = models.CharField(
220 blank=False,
221 max_length=100,
222 verbose_name=_('Name'),
223 help_text=_('Template name'),
224 )
226 description = models.CharField(
227 max_length=250,
228 verbose_name=_('Description'),
229 help_text=_('Template description'),
230 )
232 revision = models.PositiveIntegerField(
233 default=1,
234 verbose_name=_('Revision'),
235 help_text=_('Revision number (auto-increments)'),
236 editable=False,
237 )
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 )
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))
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.
257 Arguments:
258 instance: The model instance to render against
259 context: Django template language contexts (optional)
261 Returns:
262 str: HTML string
263 """
264 if context is None:
265 context = self.get_context(instance, **kwargs)
267 return render_to_string(self.template_name, context)
269 def render(
270 self, instance: models.Model, context: Optional[dict] = None, **kwargs
271 ) -> bytes:
272 """Render the template to a PDF file.
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
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 )
287 return pdf
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 )
296 enabled = models.BooleanField(
297 default=True, verbose_name=_('Enabled'), help_text=_('Template is enabled')
298 )
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 )
307 def clean(self):
308 """Clean model instance, and ensure validity."""
309 super().clean()
311 model = self.get_model()
312 filters = self.filters
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)
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)
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 )
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())
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 }
346 def get_context(self, instance: models.Model, **kwargs):
347 """Supply context data to the generic template for rendering.
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)
356 # Add in an context information provided by the model instance itself
357 context = {**base_context, **instance.report_context()}
359 return context
362class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
363 """Class representing the ReportTemplate database model."""
365 SUBDIR = 'report'
366 TEMPLATE_FIELD = 'template'
368 @staticmethod
369 def get_api_url():
370 """Return the API endpoint for the ReportTemplate model."""
371 return reverse('api-report-template-list')
373 def __init__(self, *args, **kwargs):
374 """Initialize the particular report instance."""
375 super().__init__(*args, **kwargs)
377 self._meta.get_field(
378 'page_size'
379 ).choices = report.helpers.report_page_size_options()
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 )
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 )
395 landscape = models.BooleanField(
396 default=False,
397 verbose_name=_('Landscape'),
398 help_text=_('Render report in landscape orientation'),
399 )
401 merge = models.BooleanField(
402 default=False,
403 verbose_name=_('Merge'),
404 help_text=_('Render a single report against selected items'),
405 )
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'
416 page_size = self.page_size or page_size_default
418 if self.landscape:
419 page_size = page_size + ' landscape'
421 return page_size
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 }
431 return report_context
433 def get_context(self, instance: models.Model, **kwargs):
434 """Supply context data to the report template for rendering.
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()
442 context = {**base_context, **report_context}
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
448 def get_plugin_context(self, instance: models.Model, context: dict, **kwargs):
449 """Get the context for the plugin.
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')
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)
464 return context
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 )
475 def notify_plugins(self, instance, report, user):
476 """Provide generated report to any interested plugins.
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)
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)
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.
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
505 Returns:
506 output: The DataOutput object representing the generated report(s)
508 Raises:
509 ValidationError: If there is an error during report printing
511 Notes:
512 Currently, all items are rendered separately into PDF files,
513 and then combined into a single PDF file.
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)
521 # Extract user information from the provided context
522 user = user or getattr(output, 'user', None)
524 outputs = []
526 debug_mode = get_global_setting('REPORT_DEBUG_MODE', False)
528 # Start with a default report name
529 report_name: Optional[str] = None
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 )
543 if output.progress != 0:
544 output.progress = 0
545 output.save()
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)
559 contexts = {
560 **base_context,
561 **report_context,
562 'instances': item_contexts,
563 }
565 if report_name is None:
566 report_name = self.generate_filename(contexts)
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}')
592 outputs.append(report)
593 self.handle_attachment(instance, report, report_name, user, debug_mode)
594 self.notify_plugins(instance, report, user)
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)
603 if report_name is None:
604 report_name = self.generate_filename(context)
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}')
631 outputs.append(report)
633 self.handle_attachment(
634 instance, report, report_name, user, debug_mode
635 )
637 self.notify_plugins(instance, report, user)
639 # Update the progress of the report generation
640 output.progress += 1
641 output.save()
643 except Exception as exc:
644 # Something went wrong during the report generation process
645 log_report_error('ReportTemplate.print')
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
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 })
659 if not report_name:
660 report_name = '' # pragma: no cover
662 if not report_name.endswith('.pdf'):
663 report_name += '.pdf'
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()
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)
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)
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
694 raise ValidationError(msg)
696 # Save the generated report to the database
697 generated_file = ContentFile(data, report_name)
699 output.mark_complete(output=generated_file)
701 return output
704class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
705 """Class representing the LabelTemplate database model."""
707 SUBDIR = 'label'
708 TEMPLATE_FIELD = 'template'
710 @staticmethod
711 def get_api_url():
712 """Return the API endpoint for the LabelTemplate model."""
713 return reverse('api-label-template-list')
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 )
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 )
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 )
736 def generate_page_style(self, **kwargs):
737 """Generate @page style for the label template.
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)
745 return f"""
746 @page {{
747 size: {width}mm {height}mm;
748 margin: {margin}mm;
749 }}
750 """
752 def get_context(self, instance: models.Model, *args, **kwargs):
753 """Supply context data to the label template for rendering.
755 Arguments:
756 instance: The model instance we are printing against
757 """
758 user = kwargs.get('user')
760 base_context = super().get_context(instance, **kwargs)
762 label_context: LabelContextExtension = {
763 'width': self.width,
764 'height': self.height,
765 'page_style': None,
766 }
768 context = {**base_context, **label_context}
770 if kwargs.pop('insert_page_style', True):
771 context['page_style'] = self.generate_page_style()
773 # Pass the context through to any registered plugins
774 plugins = registry.with_mixin(PluginMixinEnum.REPORT)
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)
783 return context
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.
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
803 Returns:
804 output: The DataOutput object representing the generated label(s)
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 )
813 user = user or getattr(output, 'user', None)
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 )
827 if options is None:
828 options = {}
830 try:
831 if hasattr(plugin, 'before_printing'):
832 plugin.before_printing()
834 plugin.print_labels(
835 self, output, items, None, user=user, printing_options=options
836 )
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)])
848 output.refresh_from_db()
850 # Return the output object
851 return output
854class ReportSnippet(TemplateUploadMixin, models.Model):
855 """Report template 'snippet' which can be used to make templates that can then be included in other reports.
857 Useful for 'common' template actions, sub-templates, etc
858 """
860 SUBDIR = 'snippets'
861 TEMPLATE_FIELD = 'snippet'
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 )
870 description = models.CharField(
871 max_length=250,
872 verbose_name=_('Description'),
873 help_text=_('Snippet file description'),
874 )
877class ReportAsset(TemplateUploadMixin, models.Model):
878 """Asset file for use in report templates.
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 """
885 SUBDIR = 'assets'
886 TEMPLATE_FIELD = 'asset'
888 # Asset file
889 asset = models.FileField(
890 upload_to=rename_template,
891 verbose_name=_('Asset'),
892 help_text=_('Report asset file'),
893 )
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 )