diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index b6f5692c0a..efd3344642 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -632,14 +632,14 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): return params - def check_parameter_delete(self, parameter): + def check_parameter_delete(self, parameter) -> bool: """Run a check to determine if the provided parameter can be deleted. The default implementation always returns True, but this can be overridden in the implementing class. """ return True - def check_parameter_save(self, parameter): + def check_parameter_save(self, parameter) -> bool: """Run a check to determine if the provided parameter can be saved. The default implementation always returns True, but this can be overridden in the implementing class. @@ -647,6 +647,84 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): return True +class InvenTreeNoteMixin(InvenTreePermissionCheckMixin): + """Provides an abstracted class for managing notes. + + Links the implementing model to the common.models.Note table, + and provides multiple accessor / helper methods. + """ + + class Meta: + """Metaclass options for InvenTreeNoteMixin.""" + + abstract = True + + # Define a reverse relation to the Note model + notes_list = GenericRelation( + 'common.Note', content_type_field='model_type', object_id_field='model_id' + ) + + @property + def notes(self) -> QuerySet: + """Return a queryset containing all notes for this model.""" + # Check the query cache for pre-fetched parameters + if cache := getattr(self, '_prefetched_objects_cache', None): + if 'notes_list' in cache: + return cache['notes_list'] + + return self.notes_list.all() + + def delete(self, *args, **kwargs): + """Handle the deletion of a model instance. + + Before deleting the model instance, delete any associated notes. + """ + self.notes_list.all().delete() + super().delete(*args, **kwargs) + + @transaction.atomic + def copy_notes_from(self, other, **kwargs): + """Copy all notes from another model instance. + + Arguments: + other: The other model instance to copy notes from + **kwargs: Additional keyword arguments to pass to the Note constructor + """ + import common.models + + notes = [] + + content_type = ContentType.objects.get_for_model(self.__class__) + + for note in other.notes.all(): + note.pk = None + note.model_id = self.pk + note.model_type = content_type + + notes.append(note) + + if len(notes) > 0: + common.models.Note.objects.bulk_create(notes, batch_size=250) + + def get_note(self, title: str): + """Return a Note instance for the given note title.""" + return self.notes_list.filter(title=title).first() + + def check_note_delete(self, note) -> bool: + """Run a check to determine if the provided note can be deleted. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + def check_note_save(self, note) -> bool: + """Run a check to determine if the provided note can be saved. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): """Provides an abstracted class for managing file attachments. diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0042_note.py new file mode 100644 index 0000000000..02d2737ca9 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0042_note.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.14 on 2026-05-18 13:55 + +import InvenTree.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0041_auto_20251203_1244"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Note", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "updated", + models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + ("model_id", models.PositiveIntegerField()), + ( + "title", + models.CharField( + help_text="Note title", max_length=100, verbose_name="Title" + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Optional description field", + max_length=250, + verbose_name="Description", + ), + ), + ( + "model_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "updated_by", + 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", + ), + ), + ], + options={ + "verbose_name": "Note", + "verbose_name_plural": "Notes", + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 72a7866568..e0c6f08987 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2906,6 +2906,88 @@ class Parameter( return self.template.description +class Note( + UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel +): + """Class which represents a note assigned to a particular model instance. + + Attributes: + model_type: The type of model to which this note is linked + model_id: The ID of the model to which this note is linked + user: The user who created the note + title: The title of the note + description: A description of the note (optional) + content: The content of the note + created: Date/time that the note was created + """ + + class Meta: + """Meta options for Note model.""" + + verbose_name = _('Note') + verbose_name_plural = _('Notes') + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the Parameter model.""" + return reverse('api-note-list') + + def save(self, *args, **kwargs): + """Perform custom save checks before saving a Note instance.""" + self.check_save() + super().save(*args, **kwargs) + + def delete(self): + """Perform custom delete checks before deleting a Parameter instance.""" + self.check_delete() + super().delete() + + def clean(self): + """Clean / validate the note before saving to the database.""" + # TODO: Implement this + + def check_save(self): + """Check if this note can be saved.""" + from InvenTree.models import InvenTreeNoteMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_save(self) + + def check_delete(self): + """Check if this note can be deleted.""" + from InvenTree.models import InvenTreeNoteMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_delete(self) + + model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + + model_id = models.PositiveIntegerField() + + content_object = GenericForeignKey('model_type', 'model_id') + + title = models.CharField( + max_length=100, verbose_name=_('Title'), help_text=_('Note title') + ) + + description = models.CharField( + max_length=250, + blank=True, + verbose_name=_('Description'), + help_text=_('Optional description field'), + ) + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results."""