diff --git a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py index 77ee7e3dd2..d28ed395d2 100644 --- a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py @@ -24,7 +24,7 @@ class ReportMixin: super().__init__() self.add_mixin(PluginMixinEnum.REPORT, True, __class__) - def add_report_context(self, report_instance, model_instance, request, context): + def add_report_context(self, report_instance, model_instance, user, context): """Add extra context to the provided report instance. By default, this method does nothing. @@ -32,11 +32,11 @@ class ReportMixin: Args: report_instance: The report instance to add context to model_instance: The model instance which initiated the report generation - request: The request object which initiated the report generation + user: The user to associate with the generated report context: The context dictionary to add to """ - def add_label_context(self, label_instance, model_instance, request, context): + def add_label_context(self, label_instance, model_instance, user, context): """Add extra context to the provided label instance. By default, this method does nothing. @@ -44,18 +44,18 @@ class ReportMixin: Args: label_instance: The label instance to add context to model_instance: The model instance which initiated the label generation - request: The request object which initiated the label generation + user: The user to associate with the generated label context: The context dictionary to add to """ - def report_callback(self, template, instance, report, request, **kwargs): + def report_callback(self, template, instance, report, user, **kwargs): """Callback function called after a report is generated. Arguments: template: The ReportTemplate model instance: The instance of the target model report: The generated report object - request: The initiating request object + user: The user to associate with the generated report The default implementation does nothing. """ diff --git a/src/backend/InvenTree/plugin/base/label/mixins.py b/src/backend/InvenTree/plugin/base/label/mixins.py index deab3bf097..78182e343c 100644 --- a/src/backend/InvenTree/plugin/base/label/mixins.py +++ b/src/backend/InvenTree/plugin/base/label/mixins.py @@ -38,41 +38,51 @@ class LabelPrintingMixin: BLOCKING_PRINT = True - def render_to_pdf(self, label: LabelTemplate, instance, request, **kwargs): + def render_to_pdf( + self, label: LabelTemplate, instance, request, user=None, **kwargs + ): """Render this label to PDF format. Arguments: label: The LabelTemplate object to render against instance: The model instance to render - request: The HTTP request object which triggered this print job + request: The HTTP request object which triggered this print job (optional, may be None) + user: The user who triggered this print job (optional, may be None) """ try: - return label.render(instance, request) + return label.render(instance, request=request, user=user) except Exception: log_error('render_to_pdf', plugin=self.slug) raise ValidationError(_('Error rendering label to PDF')) - def render_to_html(self, label: LabelTemplate, instance, request, **kwargs): + def render_to_html( + self, label: LabelTemplate, instance, request, user=None, **kwargs + ): """Render this label to HTML format. Arguments: label: The LabelTemplate object to render against instance: The model instance to render - request: The HTTP request object which triggered this print job + request: The HTTP request object which triggered this print job (optional, may be None) + user: The user who triggered this print job (optional, may be None) """ try: - return label.render_as_string(instance, request) + return label.render_as_string(instance, request=request, user=user) except Exception: log_error('render_to_html', plugin=self.slug) raise ValidationError(_('Error rendering label to HTML')) - def render_to_png(self, label: LabelTemplate, instance, request=None, **kwargs): + def render_to_png( + self, label: LabelTemplate, instance, request=None, user=None, **kwargs + ): """Render this label to PNG format. Arguments: label: The LabelTemplate object to render against instance: The model instance to render - request: The HTTP request object which triggered this print job + request: The HTTP request object which triggered this print job (optional, may be None) + user: The user who triggered this print job (optional, may be None) + Keyword Arguments: pdf_data: The raw PDF data of the rendered label (if already rendered) dpi: The DPI to use for the PNG rendering @@ -85,7 +95,7 @@ class LabelPrintingMixin: pdf_data = kwargs.get('pdf_data') if not pdf_data: - pdf_data = self.render_to_pdf(label, instance, request, **kwargs) + pdf_data = self.render_to_pdf(label, instance, request, user=user, **kwargs) pdf2image_kwargs = { 'dpi': kwargs.get('dpi', InvenTreeSetting.get_setting('LABEL_DPI', 300)), @@ -128,10 +138,12 @@ class LabelPrintingMixin: The default implementation simply calls print_label() for each label, producing multiple single label output "jobs" but this can be overridden by the particular plugin. """ - try: - user = request.user - except AttributeError: - user = None + # Extract user information, in decreasing order of preference + user = ( + kwargs.pop('user', None) + or getattr(request, 'user', None) + or getattr(output, 'user', None) + ) # Initial state for the output print job output.progress = 0 @@ -145,11 +157,11 @@ class LabelPrintingMixin: # Generate a label output for each provided item for item in items: - context = label.get_context(item, request) + context = label.get_context(item, request, user=user) filename = label.generate_filename(context) - pdf_data = self.render_to_pdf(label, item, request, **kwargs) + pdf_data = self.render_to_pdf(label, item, request, user=user, **kwargs) png_file = self.render_to_png( - label, item, request, pdf_data=pdf_data, **kwargs + label, item, request, pdf_data=pdf_data, user=user, **kwargs ) print_args = { diff --git a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py index 28158952a7..7654043ebd 100644 --- a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py +++ b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py @@ -20,6 +20,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): """ NAME = 'InvenTreeLabel' + SLUG = 'inventreelabel' TITLE = _('InvenTree PDF label printer') DESCRIPTION = _('Provides native support for printing PDF labels') VERSION = '1.1.0' diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index 6eebee27f6..44ff732da4 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -215,6 +215,7 @@ class LabelPrint(GenericAPIView): template.pk, [item.pk for item in items_to_print], output.pk, + user.pk if user else None, plugin.slug, options=(plugin_serializer.data if plugin_serializer else {}), ) @@ -297,7 +298,13 @@ class ReportPrint(GenericAPIView): item_ids = [item.pk for item in items_to_print] # Offload the task to the background worker - offload_task(report.tasks.print_reports, template.pk, item_ids, output.pk) + offload_task( + report.tasks.print_reports, + template.pk, + item_ids, + output.pk, + user.pk if user else None, + ) output.refresh_from_db() diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 1593ae03f9..cae928bdf4 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -148,7 +148,7 @@ class BaseContextExtension(TypedDict): 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 + user: User who is creating the report (if available) """ base_url: str @@ -244,37 +244,39 @@ class ReportTemplateBase( 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: + def render_as_string( + self, instance: models.Model, context: Optional[dict] = 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) + context = self.get_context(instance, **kwargs) - return render_to_string(self.template_name, context, request) + return render_to_string(self.template_name, context) - def render(self, instance, request=None, context=None, **kwargs) -> bytes: + def render( + self, instance: models.Model, context: Optional[dict] = 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) + context: Django template language contexts (optional) + user: The user to associate with the generated report Returns: bytes: PDF data """ - html = self.render_as_string(instance, request, context, **kwargs) + html = self.render_as_string(instance, context=context, **kwargs) pdf = HTML(string=html).write_pdf(pdf_forms=True) return pdf @@ -323,28 +325,28 @@ class ReportTemplateBase( """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: + def base_context(self, **kwargs) -> BaseContextExtension: """Return base context data (available to all templates).""" return { - 'base_url': get_base_url(request=request), + 'base_url': get_base_url(), '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, + 'user': kwargs.get('user'), } - def get_context(self, instance, request=None, **kwargs): + def get_context(self, instance: models.Model, **kwargs): """Supply context data to the generic template for rendering. Arguments: instance: The model instance we are printing against - request: The request object (optional) + user: The user to associate with the generated report """ # Provide base context information to all templates - base_context = self.base_context(request=request) + base_context = self.base_context(**kwargs) # Add in an context information provided by the model instance itself context = {**base_context, **instance.report_context()} @@ -423,55 +425,77 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): 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) + def get_context(self, instance: models.Model, **kwargs): + """Supply context data to the report template for rendering. + + Arguments: + instance: The model instance we are printing against + """ + base_context = super().get_context(instance, **kwargs) 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) + context = self.get_plugin_context(instance, context, **kwargs) return context - def get_plugin_context(self, instance, request, context): - """Get the context for the plugin.""" + def get_plugin_context(self, instance: models.Model, context: dict, **kwargs): + """Get the context for the plugin. + + Arguments: + instance: The model instance we are printing against + context: The context dictionary to add to + user: The user to associate with the generated report + """ + user = kwargs.get('user') + for plugin in registry.with_mixin(PluginMixinEnum.REPORT): try: - plugin.add_report_context(self, instance, request, context) + plugin.add_report_context(self, instance, user, 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): + def handle_attachment(self, instance, report, report_name, user, 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, + upload_user=user, ) - def notify_plugins(self, instance, report, request): - """Provide generated report to any interested plugins.""" + def notify_plugins(self, instance, report, user): + """Provide generated report to any interested plugins. + + Arguments: + instance: The model instance we are printing against + report: The generated report object + user: The user to associate with the generated report + """ report_plugins = registry.with_mixin(PluginMixinEnum.REPORT) for plugin in report_plugins: try: - plugin.report_callback(self, instance, report, request) + plugin.report_callback(self, instance, report, user) except Exception: InvenTree.exceptions.log_error('report_callback', plugin=plugin.slug) - def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput: + def print( + self, + items: list, + output: Optional[DataOutput] = None, + user: Optional[AbstractUser] = 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) + output: The DataOutput object to use + user: The user to associate with the generated report Returns: output: The DataOutput object representing the generated report(s) @@ -489,6 +513,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): """ logger.info("Printing %s reports against template '%s'", len(items), self.name) + # Extract user information from the provided context + user = user or getattr(output, 'user', None) + outputs = [] debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) @@ -500,9 +527,7 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): 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, + user=user, progress=0, complete=False, output_type=DataOutput.DataOutputTypes.REPORT, @@ -516,13 +541,13 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): try: if self.merge: - base_context = super().base_context(request) + base_context = super().base_context(user=user) 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 + instance, instance_context, user=user ) item_contexts.append(instance_context) @@ -537,9 +562,11 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): try: if debug_mode: - report = self.render_as_string(instance, request, contexts) + report = self.render_as_string( + instance, user=user, context=contexts + ) else: - report = self.render(instance, request, contexts) + report = self.render(instance, user=user, context=contexts) except TemplateDoesNotExist as e: t_name = str(e) or self.template msg = f'Template file {t_name} does not exist' @@ -558,17 +585,15 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): 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) + self.handle_attachment(instance, report, report_name, user, debug_mode) + self.notify_plugins(instance, report, user) # Update the progress of the report generation output.progress += 1 output.save() else: for instance in items: - context = self.get_context(instance, request) + context = self.get_context(instance, user=user) if report_name is None: report_name = self.generate_filename(context) @@ -576,9 +601,11 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): # Render the report output try: if debug_mode: - report = self.render_as_string(instance, request, None) + report = self.render_as_string( + instance, user=user, context=context + ) else: - report = self.render(instance, request, None) + report = self.render(instance, user=user, context=None) except TemplateDoesNotExist as e: t_name = str(e) or self.template msg = f'Template file {t_name} does not exist' @@ -599,9 +626,10 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): outputs.append(report) self.handle_attachment( - instance, report, report_name, request, debug_mode + instance, report, report_name, user, debug_mode ) - self.notify_plugins(instance, report, request) + + self.notify_plugins(instance, report, user) # Update the progress of the report generation output.progress += 1 @@ -614,7 +642,6 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): raise ValidationError({ 'error': _('Error generating report'), 'detail': str(exc), - 'path': request.path if request else None, }) if not report_name: @@ -704,9 +731,16 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): }} """ - 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) + def get_context(self, instance: models.Model, *args, **kwargs): + """Supply context data to the label template for rendering. + + Arguments: + instance: The model instance we are printing against + """ + user = kwargs.get('user') + + base_context = super().get_context(instance, **kwargs) + label_context: LabelContextExtension = { 'width': self.width, 'height': self.height, @@ -724,7 +758,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): for plugin in plugins: # Let each plugin add its own context data try: - plugin.add_label_context(self, instance, request, context) + plugin.add_label_context(self, instance, user, context) except Exception: InvenTree.exceptions.log_error('add_label_context', plugin=plugin.slug) @@ -734,9 +768,9 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): self, items: list, plugin: InvenTreePlugin, - output=None, - options=None, - request=None, + output: Optional[DataOutput] = None, + options: Optional[dict] = None, + user: Optional[AbstractUser] = None, **kwargs, ) -> DataOutput: """Print labels for a list of items against this template. @@ -744,9 +778,9 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): 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) + output: The DataOutput object to use options: Additional options for the label printing plugin (optional) - request: The request object (optional) + user: The user to associate with the generated labels Returns: output: The DataOutput object representing the generated label(s) @@ -758,11 +792,11 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): f"Printing {len(items)} labels against template '{self.name}' using plugin '{plugin.slug}'" ) + user = user or getattr(output, 'user', None) + if not output: output = DataOutput.objects.create( - user=request.user - if request and request.user.is_authenticated - else None, + user=user, total=len(items), progress=0, complete=False, @@ -779,7 +813,9 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): if hasattr(plugin, 'before_printing'): plugin.before_printing() - plugin.print_labels(self, output, items, request, printing_options=options) + plugin.print_labels( + self, output, items, None, user=user, printing_options=options + ) if hasattr(plugin, 'after_printing'): plugin.after_printing() diff --git a/src/backend/InvenTree/report/tasks.py b/src/backend/InvenTree/report/tasks.py index 0dae66f274..71106f6ccb 100644 --- a/src/backend/InvenTree/report/tasks.py +++ b/src/backend/InvenTree/report/tasks.py @@ -1,5 +1,7 @@ """Background tasks for the report app.""" +from django.contrib.auth import get_user_model + import structlog from opentelemetry import trace @@ -10,13 +12,16 @@ logger = structlog.get_logger('inventree') @tracer.start_as_current_span('print_reports') -def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwargs): +def print_reports( + template_id: int, item_ids: list[int], output_id: int, user_id: int, **kwargs +): """Print multiple reports against the provided template. Arguments: template_id: The ID of the ReportTemplate to use item_ids: List of item IDs to generate the report against - output_id: The ID of the DataOutput to use (if provided) + output_id: The ID of the DataOutput to use + user_id: The ID of the user to associate with the generated report This function is intended to be called by the background worker, and will continuously update the status of the DataOutput object. @@ -31,6 +36,18 @@ def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwarg log_error('report.tasks.print_reports') return + # Fetch user information + user = None + + if user_id: + try: + user = get_user_model().objects.get(pk=user_id) + except Exception: + log_error('report.tasks.print_reports', user_id=user_id) + + if not user: + user = getattr(output, 'user', None) + # Fetch the items to be included in the report model = template.get_model() items = model.objects.filter(pk__in=item_ids) @@ -38,20 +55,26 @@ def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwarg # Ensure they are sorted by the order of the provided item IDs items = sorted(items, key=lambda item: item_ids.index(item.pk)) - template.print(items, output=output) + template.print(items, output=output, user=user) @tracer.start_as_current_span('print_labels') def print_labels( - template_id: int, item_ids: list[int], output_id: int, plugin_slug: str, **kwargs + template_id: int, + item_ids: list[int], + output_id: int, + user_id: int, + plugin_slug: str, + **kwargs, ): """Print multiple labels against the provided template. Arguments: template_id: The ID of the LabelTemplate to use item_ids: List of item IDs to generate the labels against - output_id: The ID of the DataOutput to use (if provided) - plugin_slug: The ID of the LabelPlugin to use (if provided) + output_id: The ID of the DataOutput to use + user_id: The ID of the user to associate with the generated labels + plugin_slug: The ID of the LabelPlugin to use This function is intended to be called by the background worker, and will continuously update the status of the DataOutput object. @@ -67,6 +90,18 @@ def print_labels( log_error('report.tasks.print_labels') return + # Fetch user information + user = None + + if user_id: + try: + user = get_user_model().objects.get(pk=user_id) + except Exception: + log_error('report.tasks.print_labels', user_id=user_id) + + if not user: + user = getattr(output, 'user', None) + # Fetch the items to be included in the report model = template.get_model() items = model.objects.filter(pk__in=item_ids) @@ -83,4 +118,4 @@ def print_labels( # Extract optional arguments for label printing options = kwargs.pop('options') or {} - template.print(items, plugin, output=output, options=options) + template.print(items, plugin, output=output, user=user, options=options) diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index 6d36e7762f..e5474029ce 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -6,8 +6,12 @@ from io import StringIO from django.apps import apps from django.conf import settings from django.core.cache import cache +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.urls import reverse +from pypdf import PdfReader + import report.models as report_models from build.models import Build from common.models import Attachment @@ -299,6 +303,92 @@ class ReportTest(InvenTreeAPITestCase): self.assertIsNotNone(output.output) self.assertTrue(output.output.name.endswith('.pdf')) + def test_print_custom_template(self): + """Create a new template, print it, and check the output.""" + template_string = """ + Hello {{ user.username }} + Your user ID is {{ user.pk }}. + Template name: {{ template.name }} + {% if merge %} + REPORT OUTPUT: MERGE = ENABLED + {% for instance in instances %} + Part Name: {{ instance.part.name }} + Stock ID: {{ instance.stock_item.pk }} + {% endfor %} + {% else %} + REPORT OUTPUT: MERGE = DISABLED + Part Name: {{ part.name }} + Stock ID: {{ stock_item.pk }} + {% endif %} + """ + + template_file = ContentFile( + template_string.encode('utf-8'), name='TestPrintTemplate.html' + ) + + # Create a new report template with the above string as the template + template = ReportTemplate.objects.create( + name='Test report template', + model_type='stockitem', + template=template_file, + filename_pattern='unit_test_report.pdf', + ) + + item = StockItem.objects.first() + + test_strings = [ + f'Hello {self.user.username}', + f'Your user ID is {self.user.pk}.', + f'Template name: {template.name}', + f'Part Name: {item.part.name}', + f'Stock ID: {item.pk}', + ] + + url = reverse('api-report-print') + post_data = {'template': template.pk, 'items': [item.pk]} + + # Test with "debug" both enabled and disabled + for debug in [True, False]: + set_global_setting('REPORT_DEBUG_MODE', debug) + + # Test with "merge" both enabled and disabled + for merge in [True, False]: + template.merge = merge + template.save() + + # Generate report via the API + data = self.post(url, data=post_data).data + + self.assertEqual(data['user'], self.user.pk) + self.assertIsNotNone(data['output']) + self.assertTrue(data['output'].endswith('.html' if debug else '.pdf')) + self.assertIn('unit_test_report', data['output']) + + if debug: + # Read raw HTML file + output = default_storage.open( + data['output'].replace('/media/', '', 1) + ) + file_content = str(output.read(), 'utf-8') + else: + # Convert from PDF bytes to string for testing purposes + output_path = os.path.join( + settings.MEDIA_ROOT, data['output'].replace('/media/', '', 1) + ) + reader = PdfReader(output_path) + file_content = ''.join(page.extract_text() for page in reader.pages) + + # Replace any newline characters for testing purposes + file_content = file_content.replace('\n', ' ') + + for ts in test_strings: + self.assertIn(ts, file_content) + + self.assertIn( + f'REPORT OUTPUT: MERGE = {"ENABLED" if merge else "DISABLED"}', + file_content, + ) + class LabelTest(InvenTreeAPITestCase): """Unit tests for label templates.""" @@ -351,6 +441,71 @@ class LabelTest(InvenTreeAPITestCase): self.assertEqual(output.plugin, 'inventreelabel') self.assertTrue(output.output.name.endswith('.pdf')) + def test_print_custom_template(self): + """Test printing against a custom template file.""" + template_string = """ + Hello {{ user.username }} - your user ID is {{ user.pk }}. + Template name: {{ template.name }} + Barcode: {{ qr_data }} + Location ID: {{ location.pk }} + Base URL: {{ base_url }} + """ + + template_file = ContentFile( + template_string.encode('utf-8'), name='TestLabelTemplate.html' + ) + + # Create a new label template with the above string as the template + template = LabelTemplate.objects.create( + name='Test label template', + model_type='stocklocation', + template=template_file, + filename_pattern='unit_test_label.pdf', + ) + + location = StockItem.objects.exclude(location=None).first().location + + url = reverse('api-label-print') + post_data = {'template': template.pk, 'items': [location.pk]} + + plugin = registry.get_plugin('inventreelabel') + + test_strings = [ + f'Hello {self.user.username} - your user ID is {self.user.pk}.', + f'Template name: {template.name}', + f'Location ID: {location.pk}', + f'INV-SL{location.pk}', + ] + + # Test with "debug" both enabled and disabled + for debug in [True, False]: + plugin.set_setting('DEBUG', debug) + + # Generate label via the API + data = self.post(url, data=post_data).data + + self.assertEqual(data['user'], self.user.pk) + self.assertTrue(data['output'].endswith('.html' if debug else '.pdf')) + + # Read the file contents back out, and validate + if debug: + # Read raw HTML file + output = default_storage.open(data['output'].replace('/media/', '', 1)) + file_content = str(output.read(), 'utf-8') + else: + # Convert from PDF bytes to string for testing purposes + output_path = os.path.join( + settings.MEDIA_ROOT, data['output'].replace('/media/', '', 1) + ) + reader = PdfReader(output_path) + file_content = ''.join(page.extract_text() for page in reader.pages) + + # Replace any newline for testing purposes + file_content = file_content.replace('\n', ' ') + + for ts in test_strings: + self.assertIn(ts, file_content) + def test_filters(self): """Test that template filters are correctly validated.""" from django.core.exceptions import ValidationError