diff --git a/docs/docs/assets/images/concepts/notes_tab.png b/docs/docs/assets/images/concepts/notes_tab.png new file mode 100644 index 000000000000..6685e1385f0b Binary files /dev/null and b/docs/docs/assets/images/concepts/notes_tab.png differ diff --git a/docs/docs/concepts/notes.md b/docs/docs/concepts/notes.md new file mode 100644 index 000000000000..463bf444e6e6 --- /dev/null +++ b/docs/docs/concepts/notes.md @@ -0,0 +1,98 @@ +--- +title: Notes +--- + +## Notes + +*Notes* allow free-form rich-text content to be written and stored against a specific object within InvenTree. Notes can be used to record observations, instructions, historical context, or any other information associated with a model instance. + +!!! note "Business Logic" + Notes are not to be used for any core business logic within InvenTree. They are intended to provide supplementary documentation and context for objects, which can be useful for reference, communication, or reporting purposes. Plugins should not use them for storage and opt for object metadata or custom models instead. + +Notes can be associated with various InvenTree models, and each model can have multiple notes associated with it. The user interface provides a dedicated "Notes" tab on the detail page of any model that supports notes, allowing users to easily view and manage notes for that object. + +### Notes Tab + +Any model which supports notes will have a "Notes" tab on its detail page. This tab displays the content of the currently selected note, along with a sidebar listing all notes for that object by title: + +{{ image("concepts/notes-tab.png", "Notes Tab Example") }} + +## Note Fields + +Each note has the following attributes: + +| Field | Description | +| --- | --- | +| Title | A short title for the note (*required*) | +| Description | An optional brief description of the note's purpose | +| Content | The rich-text body of the note | +| Primary | Marks this note as the default note for the object | + +## Primary Note + +When a model has multiple notes, one may be designated as the *primary* note. The primary note is indicated by a {{ icon("star") }} icon in the note sidebar. + +- When the first note is created for a model instance, it is automatically set as the primary note. +- Only one note per model instance can be marked as primary at any time. +- The primary note is opened by default when navigating to the Notes tab. + +## Rich Text Editing + +Note content is edited using a rich-text (WYSIWYG) editor. The following formatting options are available: + +- **Text formatting**: Bold, italic, underline, strikethrough, inline code, code blocks +- **Headings**: H1 through H4 +- **Structure**: Blockquotes, horizontal rules +- **Lists**: Bullet lists and ordered lists +- **Links**: Insert and remove hyperlinks +- **Tables**: Insert tables; add/remove rows and columns; toggle header rows +- **Images**: Embed images uploaded directly into the note + +### Inserting Images + +Images can be embedded in note content in the following ways: + +- Click the {{ icon("photo") }} button in the editor toolbar to select a file from your device +- Paste an image directly from the clipboard +- Drag and drop an image file into the editor + +Uploaded images are stored on the server and linked to the note. If a note is edited or deleted, any images that are no longer referenced by any note are automatically removed. + +## Adding a Note + +To add a note to an object: + +1. Navigate to the object's detail page +2. Click on the **Notes** tab +3. Click the **Add Note** button +4. Fill in the `Title` (required) and optional `Description` fields +5. Click **Submit** + +The new note will appear in the sidebar ready for editing. + +## Editing Note Content + +Note content is shown in read-only mode by default. To make changes: + +1. Click the {{ icon("pencil") }} icon in the note header to enter edit mode +2. Use the toolbar to format content, insert images, or add tables +3. Click the {{ icon("device-floppy") }} icon, or press **Ctrl+S** / **Cmd+S**, to save changes +4. Click the {{ icon("check") }} icon to exit edit mode once all changes are saved + +!!! warning "Unsaved Changes" + If you navigate away from the Notes panel or leave the page while in edit mode with unsaved changes, InvenTree will prompt you to confirm before proceeding. + +### Resetting Changes + +While in edit mode, clicking the {{ icon("reload") }} icon discards any unsaved changes and reloads the last saved version of the note. + +## Editing Note Properties + +To change a note's title or description, open the actions menu in the note header and select **Edit Note**. + +## Deleting a Note + +To delete a note, open the actions menu in the note header and select **Delete Note**. + +!!! danger "Permanent Action" + Deleting a note is permanent and cannot be undone. Any images embedded in the note that are not referenced elsewhere will also be removed. diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index fb6bfbec5765..77b9a17975f1 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -23,7 +23,7 @@ Consider the use-case for your plugin and define the exact function of the plugi - Do you need to run in the background ([ScheduleMixin](./mixins/schedule.md)) or when things in InvenTree change ([EventMixin](./mixins/event.md))? - Does the plugin need configuration that should be user changeable ([SettingsMixin](./mixins/settings.md)) or static (just use a yaml in the config dir)? - You want to receive webhooks? Do not code your own untested function, use the WebhookEndpoint model as a base and override the perform_action method. -- Do you need the full power of Django with custom models and all the complexity that comes with that – welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. +- Do you need the full power of Django with custom models and all the complexity that comes with that - welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. ### Define Metadata diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a2827bb0689a..63c6b08cafe5 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -102,6 +102,7 @@ nav: - Project Codes: concepts/project_codes.md - Attachments: concepts/attachments.md - Parameters: concepts/parameters.md + - Notes: concepts/notes.md - Barcodes: - Barcode Support: barcodes/index.md - Internal Barcodes: barcodes/internal.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6997504b6f47..43807d40d671 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 499 +INVENTREE_API_VERSION = 500 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v500 -> 2026-06-02 : https://github.com/inventree/InvenTree/pull/11971 + - Removes direct "notes" field from any models which previously supported markdown notes + - Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship + - Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API + v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 - Fixes search field issues on the BarcodeScanHistory API endpoint diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 53c15f01ef34..71aeaf7da150 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -100,11 +100,12 @@ def ready(self): def remove_obsolete_tasks(self): """Delete any obsolete scheduled tasks in the database.""" obsolete = [ + 'common.tasks.delete_old_notes_images', + 'data_exporter.tasks.cleanup_old_export_outputs', 'InvenTree.tasks.delete_expired_sessions', - 'stock.tasks.delete_old_stock_items', 'label.tasks.cleanup_old_label_outputs', 'report.tasks.cleanup_old_report_outputs', - 'data_exporter.tasks.cleanup_old_export_outputs', + 'stock.tasks.delete_old_stock_items', ] try: diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 7b7cef24d524..01ad445ffe96 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -29,12 +29,6 @@ from stdimage.models import StdImageField, StdImageFieldFile from common.currency import currency_code_default -from InvenTree.sanitizer import ( - DEAFAULT_ATTRS, - DEFAULT_CSS, - DEFAULT_PROTOCOLS, - DEFAULT_TAGS, -) logger = structlog.get_logger('inventree') @@ -939,63 +933,6 @@ def remove_non_printable_characters(value: str, remove_newline=True) -> str: return cleaned -def clean_markdown(value: str) -> str: - """Clean a markdown string. - - This function will remove javascript and other potentially harmful content from the markdown string. - """ - import markdown - - try: - markdownify_settings = settings.MARKDOWNIFY['default'] - except (AttributeError, KeyError): - markdownify_settings = {} - - extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', []) - extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {}) - - # Generate raw HTML from provided markdown (without sanitizing) - # Note: The 'html' output_format is required to generate self closing tags, e.g. instead of - html = markdown.markdown( - value or '', - extensions=extensions, - extension_configs=extension_configs, - output_format='html', - ) - - # nh3 sanitizer settings - whitelist_tags = markdownify_settings.get('WHITELIST_TAGS', DEFAULT_TAGS) - whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEAFAULT_ATTRS) - whitelist_styles = markdownify_settings.get('WHITELIST_STYLES', DEFAULT_CSS) - whitelist_protocols = markdownify_settings.get( - 'WHITELIST_PROTOCOLS', DEFAULT_PROTOCOLS - ) - - # Convert bleach-style attributes (list or dict) to nh3-compatible dict format - if isinstance(whitelist_attrs, (list, tuple, set, frozenset)): - attrs_dict = {'*': set(whitelist_attrs)} - elif isinstance(whitelist_attrs, dict): - attrs_dict = {tag: set(allowed) for tag, allowed in whitelist_attrs.items()} - else: - attrs_dict = None - - # Clean the HTML content (for comparison). This must be the same as the original content - clean_html = nh3.clean( - html, - tags=set(whitelist_tags), - attributes=attrs_dict, - url_schemes=set(whitelist_protocols), - filter_style_properties=set(whitelist_styles), - link_rel=None, - strip_comments=True, - ) - - if html != clean_html: - raise ValidationError(_('Data contains prohibited markdown content')) - - return value - - def hash_barcode(barcode_data: str) -> str: """Calculate a 'unique' hash for a barcode string. diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 7d181fb99574..26d6fcf1769e 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -1,18 +1,12 @@ """Mixins for (API) views in the whole project.""" -from django.core.exceptions import FieldDoesNotExist - from rest_framework import generics, mixins, status from rest_framework.response import Response import data_exporter.mixins import importer.mixins -from InvenTree.fields import InvenTreeNotesField, OutputConfiguration -from InvenTree.helpers import ( - clean_markdown, - remove_non_printable_characters, - strip_html_tags, -) +from InvenTree.fields import OutputConfiguration +from InvenTree.helpers import remove_non_printable_characters, strip_html_tags from InvenTree.schema import schema_for_view_output_options from InvenTree.serializers import FilterableSerializerMixin @@ -54,38 +48,10 @@ def clean_string(self, field: str, data: str) -> str: """Clean / sanitize a single input string.""" cleaned = data - # By default, newline characters are removed - remove_newline = True - is_markdown = False - - try: - if hasattr(self, 'serializer_class'): - model = self.serializer_class.Meta.model - field_base = model._meta.get_field(field) - - # The following field types allow newline characters - allow_newline = [(InvenTreeNotesField, True)] - - for field_type in allow_newline: - if issubclass(type(field_base), field_type[0]): - remove_newline = False - is_markdown = field_type[1] - break - - except AttributeError: - pass - except FieldDoesNotExist: - pass - - cleaned = remove_non_printable_characters( - cleaned, remove_newline=remove_newline - ) + cleaned = remove_non_printable_characters(cleaned, remove_newline=True) cleaned = strip_html_tags(cleaned, field_name=field) - if is_markdown: - cleaned = clean_markdown(cleaned) - return cleaned def clean_data(self, data: dict) -> dict: diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 2155b2296a3d..c303eb0443b4 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -27,7 +27,6 @@ import common.settings import InvenTree.exceptions -import InvenTree.fields import InvenTree.format import InvenTree.helpers import InvenTree.helpers_model @@ -646,14 +645,14 @@ def parameters_map(self) -> dict: 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. @@ -661,6 +660,98 @@ def check_parameter_save(self, parameter): return True +class InvenTreeNoteMixin(InvenTreePermissionCheckMixin, models.Model): + """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) + + @property + def primary_note(self): + """Return the primary note for this model instance, if it exists.""" + return self.notes_list.all().order_by('-primary').first() + + def get_note(self, title: Optional[str] = None): + """Return a Note instance for the given note title. + + Arguments: + title: Title of the note to retrieve. If None, returns the primary note (if it exists) + """ + notes = self.notes_list.all().order_by('-primary') + + if title: + notes = notes.filter(title=title) + + return notes.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. @@ -1205,51 +1296,6 @@ def get_path(self) -> list: ] -class InvenTreeNotesMixin(models.Model): - """A mixin class for adding notes functionality to a model class. - - The following fields are added to any model which implements this mixin: - - - notes : A text field for storing notes - """ - - class Meta: - """Metaclass options for this mixin. - - Note: abstract must be true, as this is only a mixin, not a separate table - """ - - abstract = True - - def delete(self, *args, **kwargs): - """Custom delete method for InvenTreeNotesMixin. - - - Before deleting the object, check if there are any uploaded images associated with it. - - If so, delete the notes first - """ - from common.models import NotesImage - - images = NotesImage.objects.filter( - model_type=self.__class__.__name__.lower(), model_id=self.pk - ) - - if images.exists(): - logger.info( - 'Deleting %s uploaded images associated with %s <%s>', - images.count(), - self.__class__.__name__, - self.pk, - ) - - images.delete() - - super().delete(*args, **kwargs) - - notes = InvenTree.fields.InvenTreeNotesField( - verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') - ) - - class InvenTreeBarcodeMixin(models.Model): """A mixin class for adding barcode functionality to a model class. diff --git a/src/backend/InvenTree/InvenTree/sanitizer.py b/src/backend/InvenTree/InvenTree/sanitizer.py index ea5936c65a37..c742e7a967f5 100644 --- a/src/backend/InvenTree/InvenTree/sanitizer.py +++ b/src/backend/InvenTree/InvenTree/sanitizer.py @@ -244,7 +244,7 @@ ] # Default allowlists (matching bleach's original defaults) -# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narroy this down with the next breaking change +# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narrow this down with the next breaking change DEFAULT_TAGS = frozenset([ 'a', 'abbr', @@ -259,7 +259,7 @@ 'strong', 'ul', ]) -DEAFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} +DEFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} DEFAULT_CSS = frozenset([ 'azimuth', 'background-color', diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index fc9073dd0b7a..e22f224f8da1 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -20,7 +20,6 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.fields import empty -from rest_framework.mixins import ListModelMixin from rest_framework.permissions import SAFE_METHODS from rest_framework.serializers import DecimalField, Serializer from rest_framework.utils import model_meta @@ -814,26 +813,6 @@ def get_status_text(self, instance) -> Optional[str]: ) -class NotesFieldMixin: - """Serializer mixin for handling 'notes' fields. - - The 'notes' field will be hidden in a LIST serializer, - but available in a DETAIL serializer. - """ - - def __init__(self, *args, **kwargs): - """Remove 'notes' field from list views.""" - super().__init__(*args, **kwargs) - - if hasattr(self, 'context'): - if view := self.context.get('view', None): - if ( - issubclass(view.__class__, ListModelMixin) - and not InvenTree.ready.isGeneratingSchema() - ): - self.fields.pop('notes', None) - - class ContentTypeField(serializers.ChoiceField): """Serializer field which represents a ContentType as 'app_label.model_name'. diff --git a/src/backend/InvenTree/build/fixtures/build.yaml b/src/backend/InvenTree/build/fixtures/build.yaml index 82a52dd413c7..08d4851310ba 100644 --- a/src/backend/InvenTree/build/fixtures/build.yaml +++ b/src/backend/InvenTree/build/fixtures/build.yaml @@ -8,7 +8,6 @@ reference: "BO-0001" title: 'Building 7 parts' quantity: 7 - notes: 'Some simple notes' status: 10 # PENDING creation_date: '2019-03-16' link: http://www.google.com @@ -26,7 +25,6 @@ batch: 'B2' status: 40 # COMPLETE quantity: 21 - notes: 'Some more simple notes' creation_date: '2019-03-16' tree_id: 2 level: 0 @@ -42,7 +40,6 @@ batch: 'B2' status: 40 # COMPLETE quantity: 21 - notes: 'Some even more simple notes' creation_date: '2019-03-16' tree_id: 4 level: 0 @@ -58,7 +55,6 @@ batch: 'B4' status: 40 # COMPLETE quantity: 21 - notes: 'Some even even more simple notes' creation_date: '2019-03-16' tree_id: 5 level: 0 @@ -75,7 +71,6 @@ status: 40 # Complete quantity: 10 creation_date: '2019-03-16' - notes: "A thing" tree_id: 3 level: 0 lft: 1 diff --git a/src/backend/InvenTree/build/migrations/0059_remove_build_notes.py b/src/backend/InvenTree/build/migrations/0059_remove_build_notes.py new file mode 100644 index 000000000000..fff4c5c18a73 --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0059_remove_build_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("build", "0058_buildline_consumed"), + ("common", "0046_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="build", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index dfcf26e0b42c..df704043f983 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -79,7 +79,7 @@ class Build( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, - InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, StatusCodeMixin, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 0db1cc0b1b5d..ddb68fd12c0c 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -36,7 +36,6 @@ FilterableSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, - NotesFieldMixin, OptionalField, ) from stock.generators import generate_batch_code @@ -56,7 +55,6 @@ class BuildSerializer( CustomStatusSerializerMixin, FilterableSerializerMixin, - NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer, @@ -94,7 +92,6 @@ class Meta: 'status_custom_key', 'target_date', 'take_from', - 'notes', 'link', 'issued_by', 'issued_by_detail', @@ -1522,12 +1519,9 @@ def annotate_queryset(queryset, build=None): # Defer expensive fields which we do not need for this serializer queryset = queryset.defer( - 'build__notes', 'build__metadata', 'bom_item__metadata', - 'bom_item__part__notes', 'bom_item__part__metadata', - 'bom_item__sub_part__notes', 'bom_item__sub_part__metadata', ) diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index e35d269f0b0b..e90787d509c4 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -50,6 +50,16 @@ class SelectionListAdmin(admin.ModelAdmin): inlines = [SelectionListEntryInlineAdmin] +@admin.register(common.models.Note) +class NoteAdmin(admin.ModelAdmin): + """Admin interface for Note objects.""" + + list_display = ('title', 'model_type', 'model_id') + + list_filter = ('model_type',) + search_fields = ('title', 'description') + + @admin.register(common.models.Attachment) class AttachmentAdmin(admin.ModelAdmin): """Admin interface for Attachment objects.""" diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 5726b32f91bc..e1f3068076ce 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -465,17 +465,38 @@ def get_object(self): admin_router.register('config', ConfigViewSet, basename='api-config') +class NotesImageFilter(FilterSet): + """Filterset for the NotesImage API endpoint.""" + + class Meta: + """Metaclass options.""" + + model = common.models.NotesImage + fields = ['user', 'note'] + + model_id = rest_filters.NumberFilter( + label=_('Model ID'), field_name='note__model_id' + ) + + 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, 'note__model_type', value, allow_null=False + ) + + class NotesImageList(ListCreateAPI): """List view for all notes images.""" queryset = common.models.NotesImage.objects.all() serializer_class = common.serializers.NotesImageSerializer permission_classes = [IsAuthenticatedOrReadScope] + filterset_class = NotesImageFilter filter_backends = SEARCH_ORDER_FILTER - search_fields = ['user', 'model_type', 'model_id'] - def perform_create(self, serializer): """Create (upload) a new notes image.""" image = serializer.save() @@ -829,6 +850,52 @@ def destroy(self, request, *args, **kwargs): 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.""" + + # Ignore default sanitizing of the 'content' field + # Note: This is handled explicitly in the 'save' method of the Note model + SAFE_FIELDS = ['content'] + + 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 = '-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'] + + +class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Note objects.""" + + class ParameterTemplateFilter(FilterSet): """FilterSet class for the ParameterTemplateList API endpoint.""" @@ -1454,8 +1521,6 @@ def create(self, request, *args, **kwargs): common_api_urls = [ # Webhooks path('webhook//', WebhookView.as_view(), name='api-webhook'), - # Uploaded images for notes - path('notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'), # Background task information path( 'background-task/', @@ -1487,6 +1552,22 @@ def create(self, request, *args, **kwargs): path('', AttachmentList.as_view(), name='api-attachment-list'), ]), ), + # Notes + path( + 'note/', + include([ + # Uploaded images for notes + path('image/', NotesImageList.as_view(), name='api-notes-image-list'), + 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/0024_notesimage_model_id_notesimage_model_type.py b/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py index 24467f9ba233..681cd2633abb 100644 --- a/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py +++ b/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='notesimage', name='model_type', - field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_notes_model_type]), + field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100), ), ] diff --git a/src/backend/InvenTree/common/migrations/0044_note.py b/src/backend/InvenTree/common/migrations/0044_note.py new file mode 100644 index 000000000000..31045b879580 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0044_note.py @@ -0,0 +1,124 @@ +# Generated by Django 5.2.14 on 2026-05-18 14:16 + +import common.validators +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", "0043_auto_20260518_1206"), + ("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", + ), + ), + ( + "content", + models.TextField( + blank=True, help_text="Note content", verbose_name="Content", max_length=50000 + ), + ), + ( + "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", + ), + ), + ( + "primary", + models.BooleanField( + default=False, + help_text="Is this the primary note for the associated model?", + verbose_name="Primary", + ), + ) + ], + options={ + "verbose_name": "Note", + "verbose_name_plural": "Notes", + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + # Once the 'Note' model has been created, we can add the foreign key to the 'NotesImage' model + # This will (initially) allow null values, so that existing images are not affected + # After the data migration, we will come back and mark this field as non-nullable + migrations.AddField( + model_name="notesimage", + name="note", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="common.note", + related_name='images', + ), + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0045_auto_20260525_0956.py b/src/backend/InvenTree/common/migrations/0045_auto_20260525_0956.py new file mode 100644 index 000000000000..251bfca76ba4 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0045_auto_20260525_0956.py @@ -0,0 +1,142 @@ +# Generated by Django 5.2.14 on 2026-05-25 09:56 + +from tqdm import tqdm + +from django.db import migrations + + +def get_markdownify_settings() -> dict: + """Return the settings for markdownify, or an empty dict if not defined.""" + + from django.conf import settings + + try: + return settings.MARKDOWNIFY['default'] + except (AttributeError, KeyError): + return {} + + +def markdown_to_html(value: str) -> str: + """Convert a markdown string to HTML. + + This function will remove javascript and other potentially harmful content from the markdown string. + """ + import markdown + + markdownify_settings = get_markdownify_settings() + extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', []) + extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {}) + + html = markdown.markdown( + value or '', + extensions=extensions, + extension_configs=extension_configs, + output_format='html', + ) + + return html + + +def migrate_notes(apps, schema_editor): + """Migrate existing notes to the new Note model.""" + + ContentType = apps.get_model("contenttypes", "ContentType") + + # New target models + Note = apps.get_model('common', 'Note') + NotesImage = apps.get_model('common', 'NotesImage') + + for app, model in [ + ('build', 'build'), + ('company', 'company'), + ('company', 'manufacturerpart'), + ('company', 'supplierpart'), + ('order', 'purchaseorder'), + ('order', 'returnorder'), + ('order', 'salesorder'), + ('order', 'salesordershipment'), + ('order', 'transferorder'), + ('part', 'part'), + ('stock', 'stockitem'), + ]: + # Find old model which contains the 'notes' field + OldModel = apps.get_model(app, model) + with_notes = OldModel.objects.exclude(notes__isnull=True).exclude(notes='') + content_type, _created = ContentType.objects.get_or_create(app_label=app, model=model) + + if not with_notes.exists(): + continue + + progress = tqdm(total=with_notes.count(), desc=f'Migration common.0045: Migrating notes for {app}.{model}') + + initial_note_count = Note.objects.count() + expected_note_count = initial_note_count + with_notes.count() + + for instance in with_notes: + + content = markdown_to_html(instance.notes) + + note = Note.objects.create( + title="Note", # We don't have a title field in the old model, so we'll just use a default value + content=content, + model_type=content_type, + model_id=instance.pk, + primary=True + ) + + # Find any existing NotesImage objects associated with this instance + images = NotesImage.objects.filter(model_type__iexact=model, model_id=instance.pk) + images.update(note=note) + + # Find any notes images which do not directly reference the model instance, but are still referenced in the markdown content + unlinked_images = NotesImage.objects.filter( + note__isnull=True, + model_id__isnull=True + ).exclude( + image__isnull=True + ) + + for image in unlinked_images: + # If the image is referenced in the markdown content, link it to the note + if image.image.url in instance.notes: + image.note = note + image.save() + + progress.update(1) + + assert Note.objects.count() == expected_note_count, f"Expected {expected_note_count} notes, but found {Note.objects.count()} after migration." + + +def remove_unlinked_images(apps, schema_editor): + """Remove any NoteImage objects which are not linked to a Note instance.""" + + NotesImage = apps.get_model('common', 'NotesImage') + + unlinked_images = NotesImage.objects.filter(note__isnull=True) + + for image in unlinked_images: + image.delete() + + +class Migration(migrations.Migration): + + # Ensure that each app which supports 'notes' is up-to-date first + dependencies = [ + ("build", "0058_buildline_consumed"), + ("common", "0044_note"), + ("company", "0079_auto_20260212_1054"), + ("order", "0119_transferorderlineitem_line_int"), + ("part", "0150_part_maximum_stock"), + ("stock", "0123_remove_stockitem_review_needed") + ] + + operations = [ + migrations.RunPython( + code=migrate_notes, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + code=remove_unlinked_images, + reverse_code=migrations.RunPython.noop, + ) + ] diff --git a/src/backend/InvenTree/common/migrations/0046_remove_notesimage_model_id_and_more.py b/src/backend/InvenTree/common/migrations/0046_remove_notesimage_model_id_and_more.py new file mode 100644 index 000000000000..b02403472e94 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0046_remove_notesimage_model_id_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0045_auto_20260525_0956"), + ] + + operations = [ + migrations.RemoveField( + model_name="notesimage", + name="model_id", + ), + migrations.RemoveField( + model_name="notesimage", + name="model_type", + ), + migrations.AlterField( + model_name="notesimage", + name="note", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="common.note", + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 23d5352e9b7e..44f138e5fad4 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -4,6 +4,7 @@ """ import base64 +import copy import hashlib import hmac import json @@ -42,6 +43,7 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ +import nh3 import structlog from anymail.signals import inbound, tracking from django_q.signals import post_spawn @@ -1766,42 +1768,6 @@ class NewsFeedEntry(models.Model): ) -def rename_notes_image(instance, filename): - """Function for renaming uploading image file. Will store in the 'notes' directory.""" - fname = os.path.basename(filename) - return os.path.join('notes', fname) - - -class NotesImage(models.Model): - """Model for storing uploading images for the 'notes' fields of various models. - - Simply stores the image file, for use in the 'notes' field (of any models which support markdown). - """ - - image = models.ImageField( - upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') - ) - - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) - - date = models.DateTimeField(auto_now_add=True) - - model_type = models.CharField( - max_length=100, - blank=True, - null=True, - validators=[common.validators.validate_notes_model_type], - help_text=_('Target model type for this image'), - ) - - model_id = models.IntegerField( - help_text=_('Target model ID for this image'), - blank=True, - null=True, - default=None, - ) - - class CustomUnit(models.Model): """Model for storing custom physical unit definitions. @@ -2961,7 +2927,6 @@ def check_delete(self): if instance and isinstance(instance, InvenTreeParameterMixin): instance.check_parameter_delete(self) - # TODO: Reintroduce validator for model_type model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) model_id = models.PositiveIntegerField( @@ -3011,6 +2976,225 @@ def description(self): 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 + """ + + NOTES_MAX_LENGTH = 50000 + + 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() + + others = Note.objects.filter( + model_type=self.model_type, model_id=self.model_id + ).exclude(pk=self.pk) + + # If this is the *only* note for this model instance, then set it as the primary note + if not others.exists(): + self.primary = True + + self.clean() + + super().save(*args, **kwargs) + + # Once this note is saved, mark other notes as non-primary + if self.primary: + Note.objects.filter( + model_type=self.model_type, model_id=self.model_id + ).exclude(pk=self.pk).update(primary=False) + + self.cleanup_images() + + 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.""" + if self.content: + attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES) + + for tag in ( + 'span', + 'p', + 'div', + 'img', + 'a', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'pre', + 'table', + 'thead', + 'tbody', + 'tr', + 'td', + 'th', + 'colgroup', + 'col', + ): + attrs.setdefault(tag, set()).update({'style'}) + + # Allow class on structural tags used by the rich-text editor + for tag in ('div', 'span', 'img', 'table', 'td', 'th', 'col'): + attrs.setdefault(tag, set()).add('class') + + # Allow image attributes used by tiptap-extension-resizable-image + attrs.setdefault('img', set()).update({'data-keep-ratio', 'colwidth'}) + + self.content = nh3.clean( + self.content.strip(), + attributes=attrs, + filter_style_properties={ + 'color', + 'background-color', + 'font-size', + 'font-weight', + 'font-style', + 'font-family', + 'text-decoration', + 'text-align', + 'border', + 'border-color', + 'border-style', + 'border-width', + 'margin', + 'padding', + 'column-width', + 'column-height', + 'min-width', + 'max-width', + 'min-height', + 'max-height', + 'width', + 'height', + }, + ) + + 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) + + def cleanup_images(self): + """Remove any images which are no longer referenced in the note content.""" + for image in self.images.all(): + if image.image and image.image.url not in self.content: + image.delete() + + model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + + model_id = models.PositiveIntegerField() + + content_object = GenericForeignKey('model_type', 'model_id') + + primary = models.BooleanField( + default=False, + verbose_name=_('Primary'), + help_text=_('Is this the primary note for the associated model?'), + ) + + 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'), + ) + + content = models.TextField( + blank=True, + verbose_name=_('Content'), + help_text=_('Note content'), + max_length=NOTES_MAX_LENGTH, + ) + + +def rename_notes_image(instance, filename): + """Function for renaming uploading image file. Will store in the 'notes' directory.""" + fname = os.path.basename(filename) + return os.path.join('notes', fname) + + +class NotesImage(models.Model): + """Model for storing uploading images for the 'notes' fields of various models. + + Simply stores the image file, for use in the 'notes' field (of any models which support markdown). + """ + + def delete(self, *args, **kwargs): + """Ensure that the image file is deleted from storage when the NotesImage instance is deleted.""" + if self.image: + self.image.delete(save=False) + + super().delete(*args, **kwargs) + + image = models.ImageField( + upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') + ) + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + date = models.DateTimeField(auto_now_add=True) + + note = models.ForeignKey( + Note, on_delete=models.CASCADE, null=False, blank=False, related_name='images' + ) + + 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 c8e2f1392007..5b11100f78fd 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -400,7 +400,7 @@ class Meta: """Meta options for NotesImageSerializer.""" model = common_models.NotesImage - fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id'] + fields = ['pk', 'image', 'user', 'date', 'note'] read_only_fields = ['date', 'user'] @@ -820,6 +820,83 @@ def save(self, **kwargs): return super().save(**kwargs) +class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): + """Serializer for the Note model.""" + + class Meta: + """Meta options for NoteSerializer.""" + + model = common_models.Note + fields = [ + 'pk', + 'model_type', + 'model_id', + 'primary', + '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/tasks.py b/src/backend/InvenTree/common/tasks.py index 43afef5245cd..4ea1b84932f5 100644 --- a/src/backend/InvenTree/common/tasks.py +++ b/src/backend/InvenTree/common/tasks.py @@ -1,6 +1,5 @@ """Tasks (processes that get offloaded) for common app.""" -import os from datetime import timedelta from django.conf import settings @@ -15,8 +14,6 @@ import common.models import InvenTree.helpers -from InvenTree.helpers_model import getModelsWithMixin -from InvenTree.models import InvenTreeNotesMixin from InvenTree.tasks import ScheduledTask, scheduled_task tracer = trace.get_tracer(__name__) @@ -110,70 +107,6 @@ def update_news_feed(): logger.info('update_news_feed: Sync done') -@tracer.start_as_current_span('delete_old_notes_images') -@scheduled_task(ScheduledTask.DAILY) -def delete_old_notes_images(): - """Remove old notes images from the database. - - Anything older than ~3 months is removed, unless it is linked to a note - """ - try: - from common.models import NotesImage - except AppRegistryNotReady: - logger.info( - "Could not perform 'delete_old_notes_images' - App registry not ready" - ) - return - - # Remove any notes which point to non-existent image files - for note in NotesImage.objects.all(): - if not os.path.exists(note.image.path): - logger.info('Deleting note %s - image file does not exist', note.image.path) - note.delete() - - note_classes = getModelsWithMixin(InvenTreeNotesMixin) - before = InvenTree.helpers.current_date() - timedelta(days=90) - - for note in NotesImage.objects.filter(date__lte=before): - # Find any images which are no longer referenced by a note - - found = False - - img = note.image.name - - for model in note_classes: - if model.objects.filter(notes__icontains=img).exists(): - found = True - break - - if not found: - logger.info('Deleting note %s - image file not linked to a note', img) - note.delete() - - # Finally, remove any images in the notes dir which are not linked to a note - notes_dir = os.path.join(settings.MEDIA_ROOT, 'notes') - - try: - images = os.listdir(notes_dir) - except FileNotFoundError: - # Thrown if the directory does not exist - images = [] - - all_notes = NotesImage.objects.all() - - for image in images: - found = False - for note in all_notes: - img_path = os.path.basename(note.image.path) - if img_path == image: - found = True - break - - if not found: - logger.info('Deleting note %s - image file not linked to a note', image) - os.remove(os.path.join(notes_dir, image)) - - @tracer.start_as_current_span('rebuild_parameters') def rebuild_parameters(template_id): """Rebuild all parameters for a given template. diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index d4875c90146f..b0740c1b6853 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -794,6 +794,460 @@ def test_attachments(self): self.assertFalse(default_storage.exists(att.attachment.path)) +class NoteAPITests(InvenTreeAPITestCase): + """API tests for the Note model, focusing on the 'primary' flag behaviour.""" + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Test Part', description='A part for testing notes' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note(self, title, primary=None, expected_code=201): + data = {'model_type': 'part', 'model_id': self.part.pk, 'title': title} + if primary is not None: + data['primary'] = primary + return self.post(self._note_url(), data=data, expected_code=expected_code) + + def test_first_note_is_primary(self): + """A note created when no other notes exist is automatically primary.""" + response = self._create_note('Only Note') + self.assertTrue(response.data['primary']) + + def test_second_note_not_primary_by_default(self): + """Notes created after the first are not primary by default.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + + # Confirm the first is still marked primary in the database + from common.models import Note + + self.assertTrue(Note.objects.get(pk=first.data['pk']).primary) + + def test_setting_primary_clears_others(self): + """Marking a note as primary demotes all sibling notes.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + third = self._create_note('Third Note') + + # Only the first should be primary after creation + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + self.assertFalse(third.data['primary']) + + # Promote the third note via PATCH + response = self.patch( + self._note_url(third.data['pk']), data={'primary': True}, expected_code=200 + ) + self.assertTrue(response.data['primary']) + + # Verify via the list endpoint that only the third is primary + list_response = self.get( + self._note_url(), + data={'model_type': 'part', 'model_id': self.part.pk}, + expected_code=200, + ) + primary_pks = [n['pk'] for n in list_response.data if n['primary']] + self.assertEqual(primary_pks, [third.data['pk']]) + + def test_primary_flag_isolated_per_model_instance(self): + """Primary flag changes on one model instance do not affect notes on another.""" + from part.models import Part + + other_part = Part.objects.create(name='Other Part', description='Another part') + + note_a = self._create_note('Note on Part A') + self.assertTrue(note_a.data['primary']) + + # Create a note on the other part; it should be primary for *that* part + note_b_response = self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': other_part.pk, + 'title': 'Note on Part B', + }, + expected_code=201, + ) + self.assertTrue(note_b_response.data['primary']) + + # The note on Part A should still be primary + note_a_detail = self.get(self._note_url(note_a.data['pk']), expected_code=200) + self.assertTrue(note_a_detail.data['primary']) + + +class NoteContentSanitizationTests(InvenTreeAPITestCase): + """Security tests for the Note API 'content' field. + + The content field accepts raw HTML which is sanitized by nh3 before + persistence. These tests verify that known XSS vectors are neutralised + both at the model level (Note.clean()) and through the API (POST/PATCH). + """ + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Security Test Part', description='Part for note security testing' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note_with_content(self, content, expected_code=201): + return self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': self.part.pk, + 'title': 'Security Test Note', + 'content': content, + }, + expected_code=expected_code, + ) + + # ------------------------------------------------------------------------- + # Model-level sanitization (Note.clean() called directly) + # ------------------------------------------------------------------------- + + def test_model_clean_strips_script_tags(self): + """Note.clean() removes

Safe content

", + ) + note.clean() + self.assertNotIn('text

', + ) + note.clean() + self.assertNotIn('onclick', note.content.lower()) + self.assertIn('text', note.content) + + def test_model_clean_strips_javascript_protocol(self): + """Note.clean() removes javascript: from href attributes.""" + from django.contrib.contenttypes.models import ContentType + + from common.models import Note + + ct = ContentType.objects.get_for_model(self.part.__class__) + note = Note( + model_type=ct, + model_id=self.part.pk, + title='Protocol test', + content='link', + ) + note.clean() + self.assertNotIn('javascript:', note.content.lower()) + + # ------------------------------------------------------------------------- + # API - script injection (POST) + # ------------------------------------------------------------------------- + + def test_api_script_tag_stripped(self): + """

hello

" + ) + content = response.data['content'] + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn('") + self.assertNotIn('onerror', response.data['content'].lower()) + + def test_api_onload_handler_stripped(self): + """Onload attribute is stripped (e.g. on svg tags).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_onclick_handler_stripped(self): + """Onclick attribute is stripped from otherwise-allowed tags.""" + response = self._create_note_with_content("

click me

") + self.assertNotIn('onclick', response.data['content'].lower()) + + def test_api_onmouseover_handler_stripped(self): + """Onmouseover attribute is stripped.""" + response = self._create_note_with_content("hover") + self.assertNotIn('onmouseover', response.data['content'].lower()) + + def test_api_onfocus_handler_stripped(self): + """Onfocus attribute on an input element is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onfocus', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - javascript: / vbscript: protocol injection + # ------------------------------------------------------------------------- + + def test_api_javascript_href_stripped(self): + """javascript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_javascript_href_uppercase_stripped(self): + """JAVASCRIPT: href (uppercase) is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_vbscript_href_stripped(self): + """vbscript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('vbscript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - dangerous tag removal + # ------------------------------------------------------------------------- + + def test_api_iframe_stripped(self): + """" + ) + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped (prevents base-URL hijacking).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents external stylesheet injection).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents CSRF / phishing via injected forms).""" + response = self._create_note_with_content( + "
" + ) + self.assertNotIn('x" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_style_expression_stripped(self): + """IE-era CSS expression() is stripped from style attributes.""" + response = self._create_note_with_content( + '

x

' + ) + self.assertNotIn('expression(', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - SVG-based XSS + # ------------------------------------------------------------------------- + + def test_api_svg_onload_stripped(self): + """SVG with onload handler is sanitized.""" + response = self._create_note_with_content( + "" + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_svg_animate_javascript_stripped(self): + """SVG animate element with javascript: href value is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - data URI injection + # ------------------------------------------------------------------------- + + def test_api_data_uri_in_img_src_stripped(self): + """data: URI in img src containing a script payload is stripped.""" + response = self._create_note_with_content( + '' + ) + content = response.data['content'] + self.assertNotIn('Original safe content

') + pk = note.data['pk'] + + response = self.patch( + self._note_url(pk), + data={'content': "

Updated

"}, + expected_code=200, + ) + content = response.data['content'] + self.assertNotIn('', content) + self.assertIn('', content) + + def test_safe_https_link_preserved(self): + """An anchor with an https:// href is kept after sanitization.""" + response = self._create_note_with_content( + 'documentation' + ) + content = response.data['content'] + self.assertIn('https://example.com', content) + self.assertIn('documentation', content) + + def test_blockquote_preserved(self): + """Block-level formatting elements such as blockquote are preserved.""" + response = self._create_note_with_content( + '

Quoted text

' + ) + content = response.data['content'] + self.assertIn('
', content) + self.assertIn('Quoted text', content) + + def test_empty_content_accepted(self): + """An empty content field is valid and stored as-is.""" + response = self._create_note_with_content('') + self.assertEqual(response.data['content'], '') + + def test_plain_text_content_preserved(self): + """Plain text with no HTML tags is stored without modification.""" + plain = 'Just plain text, no HTML here.' + response = self._create_note_with_content(plain) + self.assertEqual(response.data['content'], plain) + + def test_html_entities_in_plain_text_not_executed(self): + """HTML-entity-encoded script tags in plain text are not executed as markup.""" + # <script> is already-escaped user text — it should be stored + # safely and not interpreted as a tag. + entity_payload = '<script>alert(1)</script>' + response = self._create_note_with_content(entity_payload) + content = response.data['content'] + # Must not contain a live