diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 05575f73a8..2ce6eff975 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -819,6 +819,47 @@ class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): return super().destroy(request, *args, **kwargs) +class NoteFilter(FilterSet): + """Filterset class for the NoteList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = common.models.Note + fields = ['model_type', 'model_id', 'updated_by'] + + 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.""" + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=False + ) + + +class NoteMixin: + """Mixin class for the Note views.""" + + queryset = common.models.Note.objects.all() + serializer_class = common.serializers.NoteSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + +class NoteList(NoteMixin, ListCreateAPI): + """List API endpoint for Note objects.""" + + filter_backends = SEARCH_ORDER_FILTER + filterset_class = NoteFilter + + ordering_fields = ['model_id', 'model_type', 'user', 'creation'] + search_fields = ['content', 'model_id', 'model_type', 'user__username'] + unique_create_fields = ['model_type', 'model_id'] + + +class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Note objects.""" + + class ParameterTemplateFilter(FilterSet): """FilterSet class for the ParameterTemplateList API endpoint.""" @@ -1477,6 +1518,20 @@ common_api_urls = [ path('', AttachmentList.as_view(), name='api-attachment-list'), ]), ), + # Notes + path( + 'note/', + include([ + path( + '/', + include([ + meta_path(common.models.Note), + path('', NoteDetail.as_view(), name='api-note-detail'), + ]), + ), + path('', NoteList.as_view(), name='api-note-list'), + ]), + ), # Parameters and templates path( 'parameter/', diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0042_note.py index 02d2737ca9..4d4dbea255 100644 --- a/src/backend/InvenTree/common/migrations/0042_note.py +++ b/src/backend/InvenTree/common/migrations/0042_note.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.14 on 2026-05-18 13:55 +# Generated by Django 5.2.14 on 2026-05-18 14:16 import InvenTree.models import django.db.models.deletion @@ -62,6 +62,12 @@ class Migration(migrations.Migration): verbose_name="Description", ), ), + ( + "content", + models.TextField( + blank=True, help_text="Note content", verbose_name="Content" + ), + ), ( "model_type", models.ForeignKey( diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index e0c6f08987..332e7fedfd 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2987,6 +2987,10 @@ class Note( help_text=_('Optional description field'), ) + content = models.TextField( + blank=True, verbose_name=_('Content'), help_text=_('Note content') + ) + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 025c28f6d0..3285404522 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -809,6 +809,82 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): return super().save(**kwargs) +class NoteSerializer(InvenTreeModelSerializer): + """Serializer for the Note model.""" + + class Meta: + """Meta options for NoteSerializer.""" + + model = common_models.Note + fields = [ + 'pk', + 'model_type', + 'model_id', + 'title', + 'description', + 'content', + 'updated', + 'updated_by', + ] + + read_only_fields = ['updated', 'updated_by'] + + def save(self, **kwargs): + """Save the Note instance.""" + from InvenTree.models import InvenTreeNoteMixin + 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 + + target_model_class = model_type.model_class() + + if not issubclass(target_model_class, InvenTreeNoteMixin): + raise PermissionDenied(_('Invalid model type specified for note')) + + permission_error_msg = _( + 'User does not have permission to create or edit notes for this model' + ) + + if not check_user_permission(user, target_model_class, 'change'): + raise PermissionDenied(permission_error_msg) + + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) + + instance = super().save(**kwargs) + instance.updated_by = user + instance.save() + + return instance + + # Note: The choices are overridden at run-time on class initialization + model_type = ContentTypeField( + mixin_class=InvenTreeParameterMixin, + choices=common.validators.note_model_options, + label=_('Model Type'), + default='', + allow_null=False, + ) + + updated_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'updated_by', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, + prefetch_fields=['updated_by'], + ) + + @register_importer() class ParameterTemplateSerializer( DataImportExportSerializerMixin, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 02c3805f96..13107bcd33 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -9,6 +9,23 @@ import common.icons from common.settings import get_global_setting +def note_model_types(): + """Return a list of valid note model choices.""" + import InvenTree.models + + return list( + InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin) + ) + + +def note_model_options(): + """Return a list of options for models which support notes.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in note_model_types() + ] + + def parameter_model_types(): """Return a list of valid parameter model choices.""" import InvenTree.models