From cf43ff37a9658b9d44b12bbe462849e14ca169a1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 13 Jun 2026 11:26:13 +0000 Subject: [PATCH] Add "template" field to Note model --- src/backend/InvenTree/common/admin.py | 7 +- src/backend/InvenTree/common/api.py | 21 ++++-- src/backend/InvenTree/common/models.py | 79 ++++++++++++++++----- src/backend/InvenTree/common/serializers.py | 49 ++++++++++--- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index e90787d509..896adee9fa 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -54,10 +54,9 @@ class SelectionListAdmin(admin.ModelAdmin): class NoteAdmin(admin.ModelAdmin): """Admin interface for Note objects.""" - list_display = ('title', 'model_type', 'model_id') - - list_filter = ('model_type',) - search_fields = ('title', 'description') + list_display = ('title', 'template', 'model_type', 'model_id', 'primary') + list_filter = ('template', 'model_type') + search_fields = ('title', 'description', 'content') @admin.register(common.models.Attachment) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 1b98924684..ad037e2d84 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -902,14 +902,16 @@ class NoteFilter(FilterSet): """Metaclass options for the filterset.""" model = common.models.Note - fields = ['model_type', 'model_id', 'updated_by'] + fields = ['model_type', 'model_id', 'updated_by', 'template'] + + template = rest_filters.BooleanFilter(label='Template') model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') def filter_model_type(self, queryset, name, value): - """Filter queryset to include only Parameters of the given model type.""" + """Filter queryset by model type, allowing null for global templates.""" return common.filters.filter_content_type( - queryset, 'model_type', value, allow_null=False + queryset, 'model_type', value, allow_null=True ) @@ -932,9 +934,16 @@ class NoteList(NoteMixin, ListCreateAPI): filterset_class = NoteFilter ordering = '-primary' - ordering_fields = ['model_id', 'model_type', 'user', 'creation', 'primary'] - search_fields = ['content', 'model_id', 'model_type', 'user__username'] - unique_create_fields = ['model_type', 'model_id'] + ordering_fields = [ + 'model_id', + 'model_type', + 'updated_by', + 'updated', + 'primary', + 'template', + 'title', + ] + search_fields = ['title', 'description', 'content'] class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index ef10fcca71..0472f91e42 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3003,7 +3003,7 @@ class Note( constraints = [ models.UniqueConstraint( fields=['model_type', 'model_id'], - condition=models.Q(primary=True), + condition=models.Q(primary=True, template=False), name='unique_primary_note_per_model', ) ] @@ -3018,30 +3018,45 @@ class Note( """Perform custom save checks before saving a Note instance.""" self.check_save() - # Lock sibling notes to serialize concurrent primary-flag updates - siblings = ( - Note.objects - .select_for_update() - .filter(model_type=self.model_type, model_id=self.model_id) - .exclude(pk=self.pk) - ) + if not self.template: + # Lock sibling notes to serialize concurrent primary-flag updates + siblings = ( + Note.objects + .select_for_update() + .filter( + model_type=self.model_type, model_id=self.model_id, template=False + ) + .exclude(pk=self.pk) + ) - # If this is the *only* note for this model instance, then set it as the primary note - if not siblings.exists(): - self.primary = True + # If this is the *only* note for this model instance, set it as primary + if not siblings.exists(): + self.primary = True - self.clean() + self.clean() + super().save(*args, **kwargs) - super().save(*args, **kwargs) - - # Once this note is saved, mark other notes as non-primary - if self.primary: - siblings.update(primary=False) + # Mark other notes as non-primary + if self.primary: + siblings.update(primary=False) + else: + # Templates skip primary-flag logic entirely + self.primary = False + self.clean() + super().save(*args, **kwargs) self.cleanup_images() def clean(self): """Clean / validate the note before saving to the database.""" + from django.core.exceptions import ValidationError + + if not self.template: + if not self.model_type: + raise ValidationError({'model_type': _('This field is required.')}) + if self.model_id is None: + raise ValidationError({'model_id': _('This field is required.')}) + if self.content: attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES) @@ -3113,6 +3128,9 @@ class Note( """Check if this note can be saved.""" from InvenTree.models import InvenTreeNoteMixin + if self.template or not self.model_type: + return + try: instance = self.content_object except InvenTree.models.InvenTreeModel.DoesNotExist: @@ -3125,6 +3143,9 @@ class Note( """Check if this note can be deleted.""" from InvenTree.models import InvenTreeNoteMixin + if self.template or not self.model_type: + return + try: instance = self.content_object except InvenTree.models.InvenTreeModel.DoesNotExist: @@ -3144,9 +3165,29 @@ class Note( if image.image and image.image.url not in self.content: image.delete() - model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + template = models.BooleanField( + default=False, + verbose_name=_('Template'), + help_text=_( + 'Is this note a template (not linked to a specific model instance)?' + ), + ) - model_id = models.PositiveIntegerField() + model_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text=_( + 'Target model type for this note (null = applies to all model types)' + ), + ) + + model_id = models.PositiveIntegerField( + null=True, + blank=True, + help_text=_('Target model instance ID for this note (null for templates)'), + ) content_object = GenericForeignKey('model_type', 'model_id') diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 9c2a9e8060..0de256bf2f 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -856,6 +856,7 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): model = common_models.Note fields = [ 'pk', + 'template', 'model_type', 'model_id', 'primary', @@ -868,17 +869,48 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): read_only_fields = ['updated', 'updated_by'] + def validate(self, data): + """Validate note data — templates need no model_id; regular notes require both.""" + data = super().validate(data) + + is_template = data.get('template', getattr(self.instance, 'template', False)) + + if not is_template: + model_type = data.get('model_type') or getattr( + self.instance, 'model_type', None + ) + model_id = data.get('model_id') or getattr(self.instance, 'model_id', None) + + if not model_type: + raise serializers.ValidationError({ + 'model_type': _('This field is required.') + }) + if model_id is None: + raise serializers.ValidationError({ + 'model_id': _('This field is required.') + }) + + return data + def save(self, **kwargs): """Save the Note instance.""" from users.permissions import check_user_permission - model_type = self.validated_data.get('model_type', None) - - if model_type is None and self.instance: - model_type = self.instance.model_type - - # Ensure that the user has permission to modify notes for the specified model user = self.context.get('request').user + is_template = self.validated_data.get( + 'template', getattr(self.instance, 'template', False) + ) + + if is_template: + if not user.is_staff: + raise PermissionDenied( + _('Only staff users can create or edit note templates') + ) + return super().save(updated_by=user, **kwargs) + + model_type = self.validated_data.get('model_type') or ( + self.instance and self.instance.model_type + ) target_model_class = model_type.model_class() @@ -902,8 +934,9 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): mixin_class=InvenTreeNoteMixin, choices=common.validators.note_model_options, label=_('Model Type'), - default='', - allow_null=False, + default=None, + allow_null=True, + required=False, ) updated_by_detail = OptionalField(