From 9ce5f2737592fde4c9a8659c86d57e65efcc5de0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 9 Apr 2026 16:10:23 +1000 Subject: [PATCH] Template Updates (#11702) * Display filename pattern in template tables * Add user update tracking to template models * Update API / serializers * Capture user information via API * Display update information in tables * Bump API version and CHANGELOG.md * Prevent double increment of revision * Fix --- CHANGELOG.md | 1 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/report/api.py | 36 +++++++---- ...dated_labeltemplate_updated_by_and_more.py | 64 +++++++++++++++++++ src/backend/InvenTree/report/models.py | 11 ++-- src/backend/InvenTree/report/serializers.py | 23 +++++++ .../src/tables/settings/TemplateTable.tsx | 40 +++++++++++- 7 files changed, 158 insertions(+), 22 deletions(-) create mode 100644 src/backend/InvenTree/report/migrations/0032_labeltemplate_updated_labeltemplate_updated_by_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fe386a040e..2828010f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#11702](https://github.com/inventree/InvenTree/pull/11702) adds "last updated" and "updated by" fields for label and report templates, allowing users to track when a template was last modified and by whom. - [#11685](https://github.com/inventree/InvenTree/pull/11685) exposes the data importer wizard to the plugin interface, allowing plugins to trigger the data importer wizard and perform custom data imports from the UI. - [#11692](https://github.com/inventree/InvenTree/pull/11692) adds line item numbering for external orders (purchase, sales and return orders). This allows users to specify a line number for each line item on the order, which can be used for reference purposes. The line number is optional, and can be left blank if not required. The line number is stored as a string, to allow for more flexible formatting (e.g. "1", "1.1", "A", etc). - [#11641](https://github.com/inventree/InvenTree/pull/11641) adds support for custom parameters against the SalesOrderShipment model. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e28b6518c4..968e6b1792 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 474 +INVENTREE_API_VERSION = 475 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v475 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11702 + - Adds "updated" and "updated_by" fields to the LabelTemplate and ReportTemplate API endpoints + v474 -> 2026-04-08 : https://github.com/inventree/InvenTree/pull/11693 - Adds DataImportMixin to the ManufacturerPartList API endpoint diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index a02bfe74ae..6eebee27f6 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -224,23 +224,27 @@ class LabelPrint(GenericAPIView): return Response(DataOutputSerializer(output).data, status=201) -class LabelTemplateList(TemplatePermissionMixin, ListCreateAPI): +class LabelTemplateMixin: + """Mixin class for label template API views.""" + + queryset = report.models.LabelTemplate.objects.all().prefetch_related('updated_by') + serializer_class = report.serializers.LabelTemplateSerializer + + +class LabelTemplateList(TemplatePermissionMixin, LabelTemplateMixin, ListCreateAPI): """API endpoint for viewing list of LabelTemplate objects.""" - queryset = report.models.LabelTemplate.objects.all() - serializer_class = report.serializers.LabelTemplateSerializer filterset_class = LabelFilter filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] search_fields = ['name', 'description'] ordering_fields = ['name', 'enabled'] -class LabelTemplateDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): +class LabelTemplateDetail( + TemplatePermissionMixin, LabelTemplateMixin, RetrieveUpdateDestroyAPI +): """Detail API endpoint for label template model.""" - queryset = report.models.LabelTemplate.objects.all() - serializer_class = report.serializers.LabelTemplateSerializer - class ReportPrint(GenericAPIView): """API endpoint for printing reports.""" @@ -300,23 +304,27 @@ class ReportPrint(GenericAPIView): return Response(DataOutputSerializer(output).data, status=201) -class ReportTemplateList(TemplatePermissionMixin, ListCreateAPI): +class ReportTemplateMixin: + """Mixin class for report template API views.""" + + queryset = report.models.ReportTemplate.objects.all().prefetch_related('updated_by') + serializer_class = report.serializers.ReportTemplateSerializer + + +class ReportTemplateList(TemplatePermissionMixin, ReportTemplateMixin, ListCreateAPI): """API endpoint for viewing list of ReportTemplate objects.""" - queryset = report.models.ReportTemplate.objects.all() - serializer_class = report.serializers.ReportTemplateSerializer filterset_class = ReportFilter filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] search_fields = ['name', 'description'] ordering_fields = ['name', 'enabled'] -class ReportTemplateDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): +class ReportTemplateDetail( + TemplatePermissionMixin, ReportTemplateMixin, RetrieveUpdateDestroyAPI +): """Detail API endpoint for report template model.""" - queryset = report.models.ReportTemplate.objects.all() - serializer_class = report.serializers.ReportTemplateSerializer - class ReportSnippetList(TemplatePermissionMixin, ListCreateAPI): """API endpoint for listing ReportSnippet objects.""" diff --git a/src/backend/InvenTree/report/migrations/0032_labeltemplate_updated_labeltemplate_updated_by_and_more.py b/src/backend/InvenTree/report/migrations/0032_labeltemplate_updated_labeltemplate_updated_by_and_more.py new file mode 100644 index 0000000000..aaf2c9024e --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0032_labeltemplate_updated_labeltemplate_updated_by_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.12 on 2026-04-09 01:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("report", "0031_reporttemplate_merge"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="labeltemplate", + name="updated", + field=models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + migrations.AddField( + model_name="labeltemplate", + name="updated_by", + field=models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + migrations.AddField( + model_name="reporttemplate", + name="updated", + field=models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + migrations.AddField( + model_name="reporttemplate", + name="updated_by", + field=models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ] diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 8ab0168899..1593ae03f9 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -27,7 +27,7 @@ import InvenTree.helpers import InvenTree.models import report.helpers import report.validators -from common.models import DataOutput, RenderChoices +from common.models import DataOutput, RenderChoices, UpdatedUserMixin from common.settings import get_global_setting from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin @@ -189,7 +189,9 @@ class ReportContextExtension(TypedDict): merge: bool -class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): +class ReportTemplateBase( + MetadataMixin, UpdatedUserMixin, InvenTree.models.InvenTreeModel +): """Base class for reports, labels.""" class ModelChoices(RenderChoices): @@ -205,8 +207,9 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): def save(self, *args, **kwargs): """Perform additional actions when the report is saved.""" - # Increment revision number - self.revision += 1 + if kwargs.pop('increment_revision', True): + # Increment revision number + self.revision += 1 super().save() diff --git a/src/backend/InvenTree/report/serializers.py b/src/backend/InvenTree/report/serializers.py index 5cf2e37e6b..1845724257 100644 --- a/src/backend/InvenTree/report/serializers.py +++ b/src/backend/InvenTree/report/serializers.py @@ -11,6 +11,7 @@ from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeModelSerializer, ) +from users.serializers import UserSerializer class ReportSerializerBase(InvenTreeModelSerializer): @@ -27,6 +28,21 @@ class ReportSerializerBase(InvenTreeModelSerializer): if len(self.fields['model_type'].choices) == 0: self.fields['model_type'].choices = report.helpers.report_model_options() + def save(self, **kwargs): + """Override the save method to capture the user information.""" + user = self.context.get('request').user + + if not user or not user.is_authenticated: + raise PermissionError( + _('User must be authenticated to save report templates') + ) + + instance = super().save(**kwargs) + instance.updated_by = user + instance.save(increment_revision=False) + + return instance + @staticmethod def base_fields(): """Base serializer field set.""" @@ -41,6 +57,9 @@ class ReportSerializerBase(InvenTreeModelSerializer): 'enabled', 'revision', 'attach_to_model', + 'updated', + 'updated_by', + 'updated_by_detail', ] template = InvenTreeAttachmentSerializerField(required=True) @@ -56,6 +75,10 @@ class ReportSerializerBase(InvenTreeModelSerializer): allow_null=False, ) + updated_by_detail = UserSerializer( + source='updated_by', read_only=True, allow_null=True, many=False + ) + class ReportTemplateSerializer(ReportSerializerBase): """Serializer class for report template model.""" diff --git a/src/frontend/src/tables/settings/TemplateTable.tsx b/src/frontend/src/tables/settings/TemplateTable.tsx index cddbbe517b..4c75b10b4f 100644 --- a/src/frontend/src/tables/settings/TemplateTable.tsx +++ b/src/frontend/src/tables/settings/TemplateTable.tsx @@ -38,6 +38,7 @@ import type { TemplateEditorUIFeature, TemplatePreviewUIFeature } from '../../components/plugins/PluginUIFeatureTypes'; +import { formatDate } from '../../defaults/formatters'; import { useFilters } from '../../hooks/UseFilter'; import { useCreateApiFormModal, @@ -48,8 +49,13 @@ import { useInstance } from '../../hooks/UseInstance'; import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; -import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers'; +import { + BooleanColumn, + DescriptionColumn, + UserColumn +} from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; +import { TableHoverCard } from '../TableHoverCard'; export type TemplateI = { pk: number; @@ -233,12 +239,40 @@ export function TemplateTable({ { accessor: 'revision', sortable: false, - switchable: true + switchable: true, + render: (record: any) => { + return ( + + {record.revision} + {record.updated && ( + {formatDate(record.updated)}} + /> + )} + + ); + } }, + UserColumn({ + accessor: 'updated_by_detail', + sortable: false, + defaultVisible: false, + title: t`Updated By` + }), { accessor: 'filters', sortable: false, - switchable: true + switchable: true, + defaultVisible: false + }, + { + accessor: 'filename_pattern', + title: t`Filename`, + sortable: false, + switchable: true, + defaultVisible: false }, ...Object.entries(additionalFormFields || {}).map(([key, field]) => ({ accessor: key,