diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 89659bcdc0..472cba0010 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,7 +17,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.11.0
+ rev: v0.11.6
hooks:
- id: ruff-format
args: [--preview]
@@ -28,7 +28,7 @@ repos:
--preview
]
- repo: https://github.com/astral-sh/uv-pre-commit
- rev: 0.6.6
+ rev: 0.6.14
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in
@@ -70,13 +70,13 @@ repos:
src/frontend/vite.config.ts |
)$
- repo: https://github.com/biomejs/pre-commit
- rev: v1.9.4
+ rev: v2.0.0-beta.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
files: ^src/frontend/.*\.(js|ts|tsx)$
- repo: https://github.com/gitleaks/gitleaks
- rev: v8.24.0
+ rev: v8.24.3
hooks:
- id: gitleaks
language_version: 1.23.6
diff --git a/src/backend/InvenTree/report/migrations/0030_reporttemplate_merge.py b/src/backend/InvenTree/report/migrations/0030_reporttemplate_merge.py
new file mode 100644
index 0000000000..f08127369e
--- /dev/null
+++ b/src/backend/InvenTree/report/migrations/0030_reporttemplate_merge.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.20 on 2025-04-03 01:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("report", "0029_remove_reportoutput_template_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="reporttemplate",
+ name="merge",
+ field=models.BooleanField(
+ default=False,
+ help_text="Render a single report against selected items",
+ verbose_name="Merge",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py
index 4383f5b833..b8ed4784d4 100644
--- a/src/backend/InvenTree/report/models.py
+++ b/src/backend/InvenTree/report/models.py
@@ -170,10 +170,12 @@ class ReportContextExtension(TypedDict):
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):
@@ -366,6 +368,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
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:
@@ -382,14 +390,21 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
return page_size
- 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_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
@@ -455,21 +470,23 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
output.save()
try:
- for instance in items:
- context = self.get_context(instance, request)
+ if self.merge:
+ base_context = super().base_context(request)
+ report_context = self.get_report_context()
+ item_contexts = []
+ for instance in items:
+ item_contexts.append(instance.report_context())
+ contexts = {
+ **base_context,
+ **report_context,
+ 'instances': item_contexts,
+ }
if report_name is None:
- report_name = self.generate_filename(context)
+ report_name = self.generate_filename(contexts)
- # Render the report output
- try:
- if debug_mode:
- report = self.render_as_string(instance, request)
- else:
- report = self.render(instance, request)
- except TemplateDoesNotExist as e:
- t_name = str(e) or self.template
- raise ValidationError(f'Template file {t_name} does not exist')
+ html = render_to_string(self.template_name, contexts, request)
+ report = HTML(string=html).write_pdf()
outputs.append(report)
@@ -495,6 +512,47 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# 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)
+ else:
+ report = self.render(instance, request)
+ except TemplateDoesNotExist as e:
+ t_name = str(e) or self.template
+ raise ValidationError(f'Template file {t_name} does not exist')
+
+ outputs.append(report)
+
+ # 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,
+ )
+
+ # Provide generated report to any interested plugins
+ for plugin in report_plugins:
+ try:
+ plugin.report_callback(self, instance, report, request)
+ except Exception:
+ InvenTree.exceptions.log_error(
+ f'plugins.{plugin.slug}.report_callback'
+ )
+
+ # Update the progress of the report generation
+ output.progress += 1
+ output.save()
except Exception as exc:
# Something went wrong during the report generation process
diff --git a/src/backend/InvenTree/report/serializers.py b/src/backend/InvenTree/report/serializers.py
index fe542742ff..0f133898f6 100644
--- a/src/backend/InvenTree/report/serializers.py
+++ b/src/backend/InvenTree/report/serializers.py
@@ -65,7 +65,12 @@ class ReportTemplateSerializer(ReportSerializerBase):
"""Metaclass options."""
model = report.models.ReportTemplate
- fields = [*ReportSerializerBase.base_fields(), 'page_size', 'landscape']
+ fields = [
+ *ReportSerializerBase.base_fields(),
+ 'page_size',
+ 'landscape',
+ 'merge',
+ ]
page_size = serializers.ChoiceField(
required=False,
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx
index 6e107fc954..a098760d4c 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx
@@ -21,6 +21,12 @@ function ReportTemplateTable() {
)
},
+ merge: {
+ label: t`Merge`,
+ modelRenderer: (instance: any) => (
+
+ )
+ },
attach_to_model: {
label: t`Attach to Model`,
modelRenderer: (instance: any) => (