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
+3 -4
View File
@@ -54,10 +54,9 @@ class SelectionListAdmin(admin.ModelAdmin):
class NoteAdmin(admin.ModelAdmin): class NoteAdmin(admin.ModelAdmin):
"""Admin interface for Note objects.""" """Admin interface for Note objects."""
list_display = ('title', 'model_type', 'model_id') list_display = ('title', 'template', 'model_type', 'model_id', 'primary')
list_filter = ('template', 'model_type')
list_filter = ('model_type',) search_fields = ('title', 'description', 'content')
search_fields = ('title', 'description')
@admin.register(common.models.Attachment) @admin.register(common.models.Attachment)
+15 -6
View File
@@ -902,14 +902,16 @@ class NoteFilter(FilterSet):
"""Metaclass options for the filterset.""" """Metaclass options for the filterset."""
model = common.models.Note 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') model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type')
def filter_model_type(self, queryset, name, value): 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( 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 filterset_class = NoteFilter
ordering = '-primary' ordering = '-primary'
ordering_fields = ['model_id', 'model_type', 'user', 'creation', 'primary'] ordering_fields = [
search_fields = ['content', 'model_id', 'model_type', 'user__username'] 'model_id',
unique_create_fields = ['model_type', 'model_id'] 'model_type',
'updated_by',
'updated',
'primary',
'template',
'title',
]
search_fields = ['title', 'description', 'content']
class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI):
+60 -19
View File
@@ -3003,7 +3003,7 @@ class Note(
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=['model_type', 'model_id'], fields=['model_type', 'model_id'],
condition=models.Q(primary=True), condition=models.Q(primary=True, template=False),
name='unique_primary_note_per_model', name='unique_primary_note_per_model',
) )
] ]
@@ -3018,30 +3018,45 @@ class Note(
"""Perform custom save checks before saving a Note instance.""" """Perform custom save checks before saving a Note instance."""
self.check_save() self.check_save()
# Lock sibling notes to serialize concurrent primary-flag updates if not self.template:
siblings = ( # Lock sibling notes to serialize concurrent primary-flag updates
Note.objects siblings = (
.select_for_update() Note.objects
.filter(model_type=self.model_type, model_id=self.model_id) .select_for_update()
.exclude(pk=self.pk) .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 this is the *only* note for this model instance, set it as primary
if not siblings.exists(): if not siblings.exists():
self.primary = True self.primary = True
self.clean() self.clean()
super().save(*args, **kwargs)
super().save(*args, **kwargs) # Mark other notes as non-primary
if self.primary:
# Once this note is saved, mark other notes as non-primary siblings.update(primary=False)
if self.primary: else:
siblings.update(primary=False) # Templates skip primary-flag logic entirely
self.primary = False
self.clean()
super().save(*args, **kwargs)
self.cleanup_images() self.cleanup_images()
def clean(self): def clean(self):
"""Clean / validate the note before saving to the database.""" """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: if self.content:
attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES) attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES)
@@ -3113,6 +3128,9 @@ class Note(
"""Check if this note can be saved.""" """Check if this note can be saved."""
from InvenTree.models import InvenTreeNoteMixin from InvenTree.models import InvenTreeNoteMixin
if self.template or not self.model_type:
return
try: try:
instance = self.content_object instance = self.content_object
except InvenTree.models.InvenTreeModel.DoesNotExist: except InvenTree.models.InvenTreeModel.DoesNotExist:
@@ -3125,6 +3143,9 @@ class Note(
"""Check if this note can be deleted.""" """Check if this note can be deleted."""
from InvenTree.models import InvenTreeNoteMixin from InvenTree.models import InvenTreeNoteMixin
if self.template or not self.model_type:
return
try: try:
instance = self.content_object instance = self.content_object
except InvenTree.models.InvenTreeModel.DoesNotExist: except InvenTree.models.InvenTreeModel.DoesNotExist:
@@ -3144,9 +3165,29 @@ class Note(
if image.image and image.image.url not in self.content: if image.image and image.image.url not in self.content:
image.delete() 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') content_object = GenericForeignKey('model_type', 'model_id')
+41 -8
View File
@@ -856,6 +856,7 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
model = common_models.Note model = common_models.Note
fields = [ fields = [
'pk', 'pk',
'template',
'model_type', 'model_type',
'model_id', 'model_id',
'primary', 'primary',
@@ -868,17 +869,48 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
read_only_fields = ['updated', 'updated_by'] 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): def save(self, **kwargs):
"""Save the Note instance.""" """Save the Note instance."""
from users.permissions import check_user_permission 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 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() target_model_class = model_type.model_class()
@@ -902,8 +934,9 @@ class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
mixin_class=InvenTreeNoteMixin, mixin_class=InvenTreeNoteMixin,
choices=common.validators.note_model_options, choices=common.validators.note_model_options,
label=_('Model Type'), label=_('Model Type'),
default='', default=None,
allow_null=False, allow_null=True,
required=False,
) )
updated_by_detail = OptionalField( updated_by_detail = OptionalField(