"""Report template model definitions.""" import io import os import sys from datetime import date, datetime from typing import Optional, TypedDict, cast from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.validators import FileExtensionValidator, MinValueValidator from django.db import models from django.template import Context, Template from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ import structlog from pypdf import PdfWriter import InvenTree.exceptions import InvenTree.helpers import InvenTree.models import report.helpers import report.validators from common.models import DataOutput, RenderChoices from common.settings import get_global_setting from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin from plugin import InvenTreePlugin, PluginMixinEnum from plugin.registry import registry try: from weasyprint import HTML except OSError as err: # pragma: no cover print(f'OSError: {err}') print("Unable to import 'weasyprint' module.") print('You may require some further system packages to be installed.') sys.exit(1) logger = structlog.getLogger('inventree') def log_report_error(*args, **kwargs): """Log an error message when a report fails to render.""" try: do_log = get_global_setting('REPORT_LOG_ERRORS', backup_value=True) except Exception: do_log = True if do_log: InvenTree.exceptions.log_error(*args, **kwargs) def rename_template(instance, filename): """Function to rename a report template once uploaded. - Retains the original uploaded filename - Checks for duplicate filenames across instance class """ path = instance.get_upload_path(filename) # Throw error if any other model instances reference this path instance.check_existing_file(path, raise_error=True) # Delete file with this name if it already exists if default_storage.exists(path): logger.info(f'Deleting existing template file: {path}') default_storage.delete(path) return path class TemplateUploadMixin: """Mixin class for providing template path management functions. - Provides generic method for determining the upload path for a template - Provides generic method for checking for duplicate filenames Classes which inherit this mixin can guarantee that uploaded templates are unique, and that the same filename will be retained when uploaded. """ # Directory in which to store uploaded templates SUBDIR = '' # Name of the template field TEMPLATE_FIELD = 'template' def __str__(self) -> str: """String representation of a TemplateUploadMixin instance.""" return str(os.path.basename(self.template_name)) @property def template_name(self): """Return the filename of the template associated with this model class.""" template = getattr(self, self.TEMPLATE_FIELD).name template = template.replace('/', os.path.sep) template = template.replace('\\', os.path.sep) template = settings.MEDIA_ROOT.joinpath(template) return str(template) @property def extension(self): """Return the filename extension of the associated template file.""" return os.path.splitext(self.template.name)[1].lower() def get_upload_path(self, filename): """Generate an upload path for the given filename.""" fn = os.path.basename(filename) return os.path.join('report', self.SUBDIR, fn) def check_existing_file(self, path, raise_error=False): """Check if a file already exists with the given filename.""" filters = {self.TEMPLATE_FIELD: self.get_upload_path(path)} exists = self.__class__.objects.filter(**filters).exclude(pk=self.pk).exists() if exists and raise_error: raise ValidationError({ self.TEMPLATE_FIELD: _('Template file with this name already exists') }) return exists def validate_unique(self, exclude=None): """Validate that this template is unique.""" proposed_path = self.get_upload_path(self.template_name) self.check_existing_file(proposed_path, raise_error=True) return super().validate_unique(exclude) class BaseContextExtension(TypedDict): """Base context extension. Attributes: base_url: The base URL for the InvenTree instance date: Current date, represented as a Python datetime.date object datetime: Current datetime, represented as a Python datetime object template: The report template instance which is being rendered against template_description: Description of the report template template_name: Name of the report template template_revision: Revision of the report template user: User who made the request to render the template """ base_url: str date: date datetime: datetime template: 'ReportTemplateBase' template_description: str template_name: str template_revision: int user: Optional[AbstractUser] class LabelContextExtension(TypedDict): """Label report context extension. Attributes: width: The width of the label (in mm) height: The height of the label (in mm) 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 """ width: float height: float page_style: Optional[str] class ReportContextExtension(TypedDict): """Report context extension. Attributes: page_size: The page size of the report landscape: Boolean value, True if the report is in landscape mode merge: Boolean value, True if the a single report is generated against multiple items """ page_size: str landscape: bool merge: bool class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): """Base class for reports, labels.""" class ModelChoices(RenderChoices): """Model choices for report templates.""" choice_fnc = report.helpers.report_model_options class Meta: """Metaclass options.""" abstract = True unique_together = ('name', 'model_type') def save(self, *args, **kwargs): """Perform additional actions when the report is saved.""" # Increment revision number self.revision += 1 super().save() name = models.CharField( blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Template name'), ) description = models.CharField( max_length=250, verbose_name=_('Description'), help_text=_('Template description'), ) revision = models.PositiveIntegerField( default=1, verbose_name=_('Revision'), help_text=_('Revision number (auto-increments)'), editable=False, ) attach_to_model = models.BooleanField( default=False, verbose_name=_('Attach to Model on Print'), help_text=_( 'Save report output as an attachment against linked model instance when printing' ), ) def generate_filename(self, context, **kwargs) -> str: """Generate a filename for this report.""" template_string = Template(self.filename_pattern) return template_string.render(Context(context)) def render_as_string(self, instance, request=None, context=None, **kwargs) -> str: """Render the report to a HTML string. Arguments: instance: The model instance to render against request: A HTTPRequest object (optional) context: Django template language contexts (optional) Returns: str: HTML string """ if context is None: context = self.get_context(instance, request, **kwargs) return render_to_string(self.template_name, context, request) def render(self, instance, request=None, context=None, **kwargs) -> bytes: """Render the template to a PDF file. Arguments: instance: The model instance to render against request: A HTTPRequest object (optional) context: Django template langaguage contexts (optional) Returns: bytes: PDF data """ html = self.render_as_string(instance, request, context, **kwargs) pdf = HTML(string=html).write_pdf(pdf_forms=True) return pdf filename_pattern = models.CharField( default='output.pdf', verbose_name=_('Filename Pattern'), help_text=_('Pattern for generating filenames'), max_length=100, ) enabled = models.BooleanField( default=True, verbose_name=_('Enabled'), help_text=_('Template is enabled') ) model_type = models.CharField( max_length=100, validators=[report.validators.validate_report_model_type], verbose_name=_('Model Type'), help_text=_('Target model type for template'), ) def clean(self): """Clean model instance, and ensure validity.""" super().clean() model = self.get_model() filters = self.filters if model and filters: report.validators.validate_filters(filters, model=model) def get_model(self): """Return the database model class associated with this report template.""" return report.helpers.report_model_from_name(self.model_type) filters = models.CharField( blank=True, max_length=250, verbose_name=_('Filters'), help_text=_('Template query filters (comma-separated list of key=value pairs)'), validators=[report.validators.validate_filters], ) def get_filters(self): """Return a filter dict which can be applied to the target model.""" return report.validators.validate_filters(self.filters, model=self.get_model()) def base_context(self, request=None) -> BaseContextExtension: """Return base context data (available to all templates).""" return { 'base_url': get_base_url(request=request), 'date': InvenTree.helpers.current_date(), 'datetime': InvenTree.helpers.current_time(), 'template': self, 'template_description': self.description, 'template_name': self.name, 'template_revision': self.revision, 'user': request.user if request else None, } def get_context(self, instance, request=None, **kwargs): """Supply context data to the generic template for rendering. Arguments: instance: The model instance we are printing against request: The request object (optional) """ # Provide base context information to all templates base_context = self.base_context(request=request) # Add in an context information provided by the model instance itself context = {**base_context, **instance.report_context()} return context class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): """Class representing the ReportTemplate database model.""" SUBDIR = 'report' TEMPLATE_FIELD = 'template' @staticmethod def get_api_url(): """Return the API endpoint for the ReportTemplate model.""" return reverse('api-report-template-list') def __init__(self, *args, **kwargs): """Initialize the particular report instance.""" super().__init__(*args, **kwargs) self._meta.get_field( 'page_size' ).choices = report.helpers.report_page_size_options() template = models.FileField( upload_to=rename_template, verbose_name=_('Template'), help_text=_('Template file'), validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) page_size = models.CharField( max_length=20, default=report.helpers.report_page_size_default, verbose_name=_('Page Size'), help_text=_('Page size for PDF reports'), ) landscape = models.BooleanField( default=False, verbose_name=_('Landscape'), help_text=_('Render report in landscape orientation'), ) merge = models.BooleanField( default=False, verbose_name=_('Merge'), help_text=_('Render a single report against selected items'), ) def get_report_size(self) -> str: """Return the printable page size for this report.""" try: page_size_default = cast( str, get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False) ) except Exception: page_size_default = 'A4' page_size = self.page_size or page_size_default if self.landscape: page_size = page_size + ' landscape' return page_size def get_report_context(self): """Return report template context.""" report_context: ReportContextExtension = { 'page_size': self.get_report_size(), 'landscape': self.landscape, 'merge': self.merge, } return report_context def get_context(self, instance, request=None, **kwargs): """Supply context data to the report template for rendering.""" base_context = super().get_context(instance, request) report_context: ReportContextExtension = self.get_report_context() context = {**base_context, **report_context} # Pass the context through to the plugin registry for any additional information context = self.get_plugin_context(instance, request, context) return context def get_plugin_context(self, instance, request, context): """Get the context for the plugin.""" for plugin in registry.with_mixin(PluginMixinEnum.REPORT): try: plugin.add_report_context(self, instance, request, context) except Exception: InvenTree.exceptions.log_error('add_report_context', plugin=plugin.slug) return context def handle_attachment(self, instance, report, report_name, request, debug_mode): """Attach the generated report to the model instance (if required).""" if self.attach_to_model and not debug_mode: instance.create_attachment( attachment=ContentFile(report, report_name), comment=_(f'Report generated from template {self.name}'), upload_user=request.user if request and request.user.is_authenticated else None, ) def notify_plugins(self, instance, report, request): """Provide generated report to any interested plugins.""" report_plugins = registry.with_mixin(PluginMixinEnum.REPORT) for plugin in report_plugins: try: plugin.report_callback(self, instance, report, request) except Exception: InvenTree.exceptions.log_error('report_callback', plugin=plugin.slug) def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput: """Print reports for a list of items against this template. Arguments: items: A list of items to print reports for (model instance) output: The DataOutput object to use (if provided) request: The request object (optional) Returns: output: The DataOutput object representing the generated report(s) Raises: ValidationError: If there is an error during report printing Notes: Currently, all items are rendered separately into PDF files, and then combined into a single PDF file. Further work is required to allow the following extended features: - Render a single PDF file with the collated items (optional per template) - Render a raw file (do not convert to PDF) - allows for other file types """ logger.info("Printing %s reports against template '%s'", len(items), self.name) outputs = [] debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) # Start with a default report name report_name: Optional[str] = None # If a DataOutput object is not provided, create a new one if not output: output = DataOutput.objects.create( total=len(items), user=request.user if request and request.user and request.user.is_authenticated else None, progress=0, complete=False, output_type=DataOutput.DataOutputTypes.REPORT, template_name=self.name, output=None, ) if output.progress != 0: output.progress = 0 output.save() try: if self.merge: base_context = super().base_context(request) report_context = self.get_report_context() item_contexts = [] for instance in items: instance_context = instance.report_context() instance_context = self.get_plugin_context( instance, request, instance_context ) item_contexts.append(instance_context) contexts = { **base_context, **report_context, 'instances': item_contexts, } if report_name is None: report_name = self.generate_filename(contexts) try: if debug_mode: report = self.render_as_string(instance, request, contexts) else: report = self.render(instance, request, contexts) except TemplateDoesNotExist as e: t_name = str(e) or self.template msg = f'Template file {t_name} does not exist' output.mark_failure(error=msg) raise ValidationError(msg) except TemplateSyntaxError as e: msg = _('Template syntax error') output.mark_failure(msg) raise ValidationError(f'{msg}: {e!s}') except ValidationError as e: output.mark_failure(str(e)) raise e except Exception as e: msg = _('Error rendering report') output.mark_failure(msg) raise ValidationError(f'{msg}: {e!s}') outputs.append(report) self.handle_attachment( instance, report, report_name, request, debug_mode ) self.notify_plugins(instance, report, request) # Update the progress of the report generation output.progress += 1 output.save() else: for instance in items: context = self.get_context(instance, request) if report_name is None: report_name = self.generate_filename(context) # Render the report output try: if debug_mode: report = self.render_as_string(instance, request, None) else: report = self.render(instance, request, None) except TemplateDoesNotExist as e: t_name = str(e) or self.template msg = f'Template file {t_name} does not exist' output.mark_failure(error=msg) raise ValidationError(msg) except TemplateSyntaxError as e: msg = _('Template syntax error') output.mark_failure(error=_('Template syntax error')) raise ValidationError(f'{msg}: {e!s}') except ValidationError as e: output.mark_failure(str(e)) raise e except Exception as e: msg = _('Error rendering report') output.mark_failure(error=msg) raise ValidationError(f'{msg}: {e!s}') outputs.append(report) self.handle_attachment( instance, report, report_name, request, debug_mode ) self.notify_plugins(instance, report, request) # Update the progress of the report generation output.progress += 1 output.save() except Exception as exc: # Something went wrong during the report generation process log_report_error('ReportTemplate.print') raise ValidationError({ 'error': _('Error generating report'), 'detail': str(exc), 'path': request.path if request else None, }) if not report_name: report_name = '' # pragma: no cover if not report_name.endswith('.pdf'): report_name += '.pdf' # Combine all the generated reports into a single PDF file if debug_mode: data = '\n'.join(outputs) report_name = report_name.replace('.pdf', '.html') else: # Merge the outputs back together into a single PDF file pdf_writer = PdfWriter() try: for report in outputs: # Construct file object with raw PDF data report_file = io.BytesIO(report) pdf_writer.append(report_file) # Generate raw output pdf_file = io.BytesIO() pdf_writer.write(pdf_file) data = pdf_file.getvalue() pdf_file.close() except Exception: log_report_error('ReportTemplate.print') msg = _('Error merging report outputs') output.mark_failure(error=msg) raise ValidationError(msg) # Save the generated report to the database generated_file = ContentFile(data, report_name) output.mark_complete(output=generated_file) return output class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): """Class representing the LabelTemplate database model.""" SUBDIR = 'label' TEMPLATE_FIELD = 'template' @staticmethod def get_api_url(): """Return the API endpoint for the LabelTemplate model.""" return reverse('api-label-template-list') template = models.FileField( upload_to=rename_template, verbose_name=_('Template'), help_text=_('Template file'), validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) width = models.FloatField( default=50, verbose_name=_('Width [mm]'), help_text=_('Label width, specified in mm'), validators=[MinValueValidator(2)], ) height = models.FloatField( default=20, verbose_name=_('Height [mm]'), help_text=_('Label height, specified in mm'), validators=[MinValueValidator(2)], ) def generate_page_style(self, **kwargs): """Generate @page style for the label template. This is inserted at the top of the style block for a given label """ width = kwargs.get('width', self.width) height = kwargs.get('height', self.height) margin = kwargs.get('margin', 0) return f""" @page {{ size: {width}mm {height}mm; margin: {margin}mm; }} """ def get_context(self, instance, request=None, **kwargs): """Supply context data to the label template for rendering.""" base_context = super().get_context(instance, request, **kwargs) label_context: LabelContextExtension = { # type: ignore[invalid-assignment] 'width': self.width, 'height': self.height, 'page_style': None, } context = {**base_context, **label_context} if kwargs.pop('insert_page_style', True): context['page_style'] = self.generate_page_style() # Pass the context through to any registered plugins plugins = registry.with_mixin(PluginMixinEnum.REPORT) for plugin in plugins: # Let each plugin add its own context data try: plugin.add_label_context(self, instance, request, context) except Exception: InvenTree.exceptions.log_error('add_label_context', plugin=plugin.slug) return context def print( self, items: list, plugin: InvenTreePlugin, output=None, options=None, request=None, **kwargs, ) -> DataOutput: """Print labels for a list of items against this template. Arguments: items: A list of items to print labels for (model instance) plugin: The plugin to use for label rendering output: The DataOutput object to use (if provided) options: Additional options for the label printing plugin (optional) request: The request object (optional) Returns: output: The DataOutput object representing the generated label(s) Raises: ValidationError: If there is an error during label printing """ logger.info( f"Printing {len(items)} labels against template '{self.name}' using plugin '{plugin.slug}'" ) if not output: output = DataOutput.objects.create( user=request.user if request and request.user.is_authenticated else None, total=len(items), progress=0, complete=False, output_type=DataOutput.DataOutputTypes.LABEL, template_name=self.name, plugin=plugin.slug, output=None, ) if options is None: options = {} try: if hasattr(plugin, 'before_printing'): plugin.before_printing() plugin.print_labels(self, output, items, request, printing_options=options) if hasattr(plugin, 'after_printing'): plugin.after_printing() except ValidationError as e: output.delete() raise e except Exception as e: output.delete() InvenTree.exceptions.log_error('print_labels', plugin=plugin.slug) raise ValidationError([_('Error printing labels'), str(e)]) output.refresh_from_db() # Return the output object return output class ReportSnippet(TemplateUploadMixin, models.Model): """Report template 'snippet' which can be used to make templates that can then be included in other reports. Useful for 'common' template actions, sub-templates, etc """ SUBDIR = 'snippets' TEMPLATE_FIELD = 'snippet' snippet = models.FileField( upload_to=rename_template, verbose_name=_('Snippet'), help_text=_('Report snippet file'), validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) description = models.CharField( max_length=250, verbose_name=_('Description'), help_text=_('Snippet file description'), ) class ReportAsset(TemplateUploadMixin, models.Model): """Asset file for use in report templates. For example, an image to use in a header file. Uploaded asset files appear in MEDIA_ROOT/report/assets, and can be loaded in a template using the {% report_asset %} tag. """ SUBDIR = 'assets' TEMPLATE_FIELD = 'asset' # Asset file asset = models.FileField( upload_to=rename_template, verbose_name=_('Asset'), help_text=_('Report asset file'), ) # Asset description (user facing string, not used internally) description = models.CharField( max_length=250, verbose_name=_('Description'), help_text=_('Asset file description'), )