Add "template" field to Note model

This commit is contained in:
Oliver Walters 2026-06-13 11:26:13 +00:00
parent f5ca46e61b
commit cf43ff37a9
4 changed files with 119 additions and 37 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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')

View File

@ -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(