diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml
index b869ec0954f2..3343b6208d1d 100644
--- a/.github/workflows/frontend.yaml
+++ b/.github/workflows/frontend.yaml
@@ -136,7 +136,7 @@ jobs:
pip-dependency: psycopg2
- name: Set up test data
run: |
- invoke dev.setup-test -iv
+ invoke dev.setup-test -iv -b multi-note # TODO: Fix this before merging
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
@@ -218,7 +218,7 @@ jobs:
pip-dependency: psycopg2
- name: Set up test data
run: |
- invoke dev.setup-test -iv
+ invoke dev.setup-test -iv -b multi-note # TODO: Fix this before merging
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml
index 18dfb0dc8882..bdf00deab6a5 100644
--- a/.github/workflows/import_export.yaml
+++ b/.github/workflows/import_export.yaml
@@ -90,7 +90,7 @@ jobs:
- name: Setup Postgres Database
run: |
invoke migrate
- invoke dev.setup-test -i
+ invoke dev.setup-test -i -b multi-note # TODO: remove the -b option once the multi-notes migration is merged into master
- name: Create Plugin Data
run: |
pip install -U inventree-dummy-app-plugin==0.1.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6631a6706bb..fb0201bc2d35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
+- [#11971](https://github.com/inventree/InvenTree/pull/11971) is a major refactor of how notes are handled. Notes are now stored in a separate database table (in line with how attachments are handled), and each model instance can have multiple notes associated with it. The `notes` field has been removed from the individual models (and their associated API endpoints), and notes are now accessed via the new `/api/note/` endpoint. Existing notes data (and any embedded images) are automatically migrated to the new notes table, with the markdown content converted to HTML. Any external client applications which read or write the `notes` field via the API will need to be updated to use the new endpoint.
- [#12507](https://github.com/inventree/InvenTree/pull/12507) calling an invalid or repeated state transition now raises a ValidationError. Plugins implementing state transitions should evaluate the PR and adapt their usage of transitions to gain the new safeguards.
### Added
diff --git a/docs/docs/assets/images/build/build_notes.png b/docs/docs/assets/images/build/build_notes.png
deleted file mode 100644
index 31894bd427c0..000000000000
Binary files a/docs/docs/assets/images/build/build_notes.png and /dev/null differ
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..66dc0d70cbc8
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..f7b56a9d89de
--- /dev/null
+++ b/docs/docs/concepts/notes.md
@@ -0,0 +1,147 @@
+---
+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.
+
+## Note Templates
+
+Note templates are pre-defined notes that can be used as a starting point when adding a new note to any model instance. They allow administrators to standardize common note structures and reduce repetitive data entry.
+
+### Creating Notes from Templates
+
+When adding a new note to an object, an optional **From Template** field is available. Selecting a template pre-fills the **Title**, **Description**, and **Content** fields with the template's content. These fields can then be edited before saving.
+
+To create a note from a template:
+
+1. Navigate to the object's detail page and open the **Notes** tab
+2. Click the **Add Note** button
+3. In the **From Template** field, select an existing template from the dropdown
+4. The **Title**, **Description**, and **Content** fields are automatically populated from the template
+5. Edit any fields as needed
+6. Click **Submit** to save the note
+
+!!! info "Template Filters"
+ The template dropdown only shows templates that are applicable to the current model type, plus any templates that are not restricted to a specific model type.
+
+### Managing Note Templates
+
+Note templates are managed by staff users via the **Admin Center**.
+
+To access note templates:
+
+1. Navigate to **Settings** > **Admin Center**
+2. Select the **Note Templates** panel
+
+This panel provides the same rich-text editor interface used for regular notes. Templates created here are available to all users when adding notes across the system.
+
+#### Creating a Template
+
+1. In the **Note Templates** panel, click **Add Note Template**
+2. Enter a **Title** (required) and optional **Description**
+3. Optionally select a **Model Type** to restrict the template to a specific kind of object (e.g. *Part*, *Build Order*). Leave blank to make the template available for all model types
+4. Click **Submit**, then edit the template content in the editor
+
+#### Editing a Template
+
+Select a template from the sidebar, then use the same edit workflow as for regular notes: click the {{ icon("pencil") }} icon, make changes, and save with {{ icon("device-floppy") }} or **Ctrl+S** / **Cmd+S**.
+
+#### Deleting a Template
+
+Open the actions menu in the template header and select **Delete Note Template**.
+
+!!! note
+ Deleting a template does not affect any notes that were previously created from it.
diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md
index 496e2c1dde21..7d7c74f67f1b 100644
--- a/docs/docs/manufacturing/build.md
+++ b/docs/docs/manufacturing/build.md
@@ -211,9 +211,9 @@ Files attachments can be uploaded against the build order, and displayed in the
### Notes
-Build order notes (which support markdown formatting) are displayed in the *Notes* tab:
+One or more rich-text notes can be attached to a build order, and are displayed in the *Notes* tab.
-{{ image("build/build_notes.png", title="Notes") }}
+[Read about notes](../concepts/notes.md).
## External Build Orders
diff --git a/docs/docs/part/views.md b/docs/docs/part/views.md
index e16e8c0b81c2..9ad7cfa7ef49 100644
--- a/docs/docs/part/views.md
+++ b/docs/docs/part/views.md
@@ -149,4 +149,6 @@ The *Part Attachments* tab displays file attachments associated with the selecte
### Notes
-A part may have notes attached, which support markdown formatting.
+A part may have one or more rich-text notes attached.
+
+[Read about notes](../concepts/notes.md).
diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md
index 4bbfccb7cb82..162448dbc29b 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/docs/report/helpers.md b/docs/docs/report/helpers.md
index dbbe7306adf3..2d1723fb1404 100644
--- a/docs/docs/report/helpers.md
+++ b/docs/docs/report/helpers.md
@@ -979,9 +979,98 @@ Length: {{ length_value }}
{% endraw %}
```
+## Notes
+
+[Notes](../concepts/notes.md) are rich-text documents that can be attached to most InvenTree model instances. Two template tags are available for accessing note content in a report.
+
+### note
+
+The `note` tag returns the rendered HTML content of a note, ready to embed directly in a report. Any images embedded in the note are automatically resolved to their base64-encoded data so that they appear in the generated PDF.
+
+::: report.templatetags.report.note
+ options:
+ show_docstring_description: false
+ show_source: False
+
+If no `title` argument is given, the [primary note](../concepts/notes.md#primary-note) is returned. If a `title` is given, the note whose title matches (case-insensitively) is returned instead. An empty string is returned when no matching note exists.
+
+#### Example
+
+```html
+{% raw %}
+{% load report %}
+
+
+{% note part as part_note %}
+
{{ part_note }}
+
+
+{% note part "Assembly Instructions" as instructions %}
+{{ instructions }}
+{% endraw %}
+```
+
+!!! info "Safe HTML Output"
+ The `note` tag returns pre-sanitized HTML and is marked safe for direct template rendering. Do **not** additionally wrap it with `| safe` or `| markdownify` — the content has already been processed.
+
+### note_instance
+
+The `note_instance` tag returns the `Note` object itself, giving access to its individual fields. This is useful when you need to display the note title, description, or metadata alongside its content.
+
+::: report.templatetags.report.note_instance
+ options:
+ show_docstring_description: false
+ show_source: False
+
+A `Note` object exposes the following attributes:
+
+| Attribute | Description |
+| --- | --- |
+| `title` | The title of the note |
+| `description` | An optional short description of the note |
+| `content` | The raw HTML content of the note |
+| `primary` | `True` if this is the primary note for the model instance |
+| `updated` | Timestamp of the last modification |
+| `updated_by` | The user who last modified the note |
+
+#### Example
+
+```html
+{% raw %}
+{% load report %}
+
+{% note_instance part as primary_note %}
+{% if primary_note %}
+{{ primary_note.title }}
+{% if primary_note.description %}{{ primary_note.description }}
{% endif %}
+{% note part as note_content %}
+{{ note_content }}
+{% endif %}
+{% endraw %}
+```
+
+### Iterating Over All Notes
+
+When a model has multiple notes and you want to render all of them, access the `notes` queryset directly:
+
+```html
+{% raw %}
+{% load report %}
+
+{% for n in part.notes.all %}
+{{ n.title }}
+{% note part n.title as note_content %}
+{{ note_content }}
+{% endfor %}
+{% endraw %}
+```
+
## Rendering Markdown
-Some data fields (such as the *Notes* field available on many internal database models) support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.
+Some data fields (such as those provided by custom plugin models) may support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.
+
+!!! info "Notes"
+ [Notes](../concepts/notes.md) content is rich-text (stored as HTML) rather than markdown, and is already sanitized. Use the [note](#note) tag to render it - do not pass it through `markdownify`.
To render markdown content in a report, consider the following simplified example:
@@ -990,9 +1079,9 @@ To render markdown content in a report, consider the following simplified exampl
{% load markdownify %}
-Part Notes
+Description
- {{ part.notes | markdownify }}
+ {{ some_markdown_field | markdownify }}
{% endraw %}
```
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 833abd5f90c7..106022376559 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -107,6 +107,7 @@ nav:
- Project Codes: concepts/project_codes.md
- Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md
+ - Notes: concepts/notes.md
- Tags: concepts/tags.md
- Barcodes:
- Barcode Support: barcodes/index.md
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 0da8a25fa1fe..4b77742f5e6c 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 = 532
+INVENTREE_API_VERSION = 533
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v533 -> 2026-08-16 : 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
+
v532 -> 2026-08-15 : https://github.com/inventree/InvenTree/pull/12422
- Adds "piece_count" field to the BomItem model and API endpoints (for cut-to-length parts)
@@ -49,7 +54,7 @@
- Adds new "disassemble" API endpoint for stock items
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
-v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
+v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12334
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py
index 1cbf4d11c7d6..f1be2686621a 100644
--- a/src/backend/InvenTree/InvenTree/apps.py
+++ b/src/backend/InvenTree/InvenTree/apps.py
@@ -100,11 +100,11 @@ def ready(self):
def remove_obsolete_tasks(self):
"""Delete any obsolete scheduled tasks in the database."""
obsolete = [
+ '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/filters.py b/src/backend/InvenTree/InvenTree/filters.py
index 44319b843fb7..b4eddfe33822 100644
--- a/src/backend/InvenTree/InvenTree/filters.py
+++ b/src/backend/InvenTree/InvenTree/filters.py
@@ -37,6 +37,8 @@ def get_search_fields(self, view, request):
- search_notes: If True, 'notes' is added to the search_fields if it isn't already present
- search_regex: If True, search is performed on 'regex' comparison
"""
+ from InvenTree.models import InvenTreeNoteMixin
+
search_notes = InvenTree.helpers.str2bool(
request.query_params.get('search_notes', False)
)
@@ -45,7 +47,18 @@ def get_search_fields(self, view, request):
if search_notes and 'notes' not in search_fields:
# don't modify existing list, create a new object so further queries aren't affected
- search_fields = [*search_fields, 'notes']
+
+ model = view.get_serializer_class().Meta.model
+
+ notes_field: str = ''
+
+ if issubclass(model, InvenTreeNoteMixin):
+ notes_field = 'notes_list__content'
+ elif hasattr(model, 'notes'):
+ notes_field = 'notes'
+
+ if notes_field:
+ search_fields = [*search_fields, notes_field]
regex = InvenTree.helpers.str2bool(
request.query_params.get('search_regex', False)
diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py
index 792fd1b0020e..c4f10fe742bd 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')
@@ -954,63 +948,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 8541b5a1a9f6..d9a1ac0269a8 100644
--- a/src/backend/InvenTree/InvenTree/models.py
+++ b/src/backend/InvenTree/InvenTree/models.py
@@ -29,7 +29,6 @@
import common.settings
import InvenTree.exceptions
-import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
@@ -667,14 +666,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.
@@ -682,6 +681,159 @@ 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
+ """
+ import os
+
+ from django.core.files.base import ContentFile
+
+ import common.models
+
+ content_type = ContentType.objects.get_for_model(self.__class__)
+
+ # Prefetch each note's images in a single extra query, rather than
+ # one 'images.all()' query per note.
+ #
+ # Sort so primary note is saved last — Note.save() promotes the last
+ # note saved with primary=True, which correctly mirrors the source.
+ # This (and the resulting demotion of sibling notes) is real business
+ # logic in Note.save(), so notes must still be saved one at a time,
+ # in this order - unlike common.migrations.0051's data migration,
+ # which bulk_create()s notes directly, this can't do the same: that
+ # migration operates on a historical model with no custom
+ # save()/clean() methods at all, so there's no primary-flag logic to
+ # preserve there in the first place.
+ source_notes = sorted(
+ other.notes.all().prefetch_related('images'), key=lambda n: n.primary
+ )
+
+ for source_note in source_notes:
+ new_note = common.models.Note(
+ model_type=content_type,
+ model_id=self.pk,
+ primary=source_note.primary,
+ title=source_note.title,
+ description=source_note.description,
+ content=source_note.content,
+ )
+ new_note.save()
+
+ # Read each source image's file data and write it to storage up front,
+ # then bulk_create() all of this note's NotesImage rows in one INSERT
+ # instead of one save() per image - unlike Note, NotesImage has no
+ # save()-time business logic, so this is safe to batch.
+ new_images = []
+
+ for img in source_note.images.all():
+ if not img.image:
+ continue
+
+ old_url = img.image.url
+ filename = os.path.basename(img.image.name)
+
+ try:
+ img.image.open('rb')
+ data = img.image.read()
+ finally:
+ img.image.close()
+
+ new_img = common.models.NotesImage(note=new_note, user=img.user)
+ # save=False: still writes the file to storage (and assigns the
+ # resulting name/url), but defers the NotesImage row itself to
+ # the bulk_create() below
+ new_img.image.save(filename, ContentFile(data), save=False)
+ new_images.append((old_url, new_img))
+
+ if new_images:
+ common.models.NotesImage.objects.bulk_create([
+ new_img for _, new_img in new_images
+ ])
+
+ content_updated = False
+
+ for old_url, new_img in new_images:
+ if old_url in new_note.content:
+ new_note.content = new_note.content.replace(
+ old_url, new_img.image.url
+ )
+ content_updated = True
+
+ if content_updated:
+ new_note.save()
+
+ @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.
@@ -1321,51 +1473,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 InvenTreeTagsMixin(models.Model):
"""A mixin class for adding tag 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/sentry.py b/src/backend/InvenTree/InvenTree/sentry.py
index bf1252b331fc..65081090dda0 100644
--- a/src/backend/InvenTree/InvenTree/sentry.py
+++ b/src/backend/InvenTree/InvenTree/sentry.py
@@ -81,7 +81,7 @@ def report_exception(exc, scope: Optional[dict] = None): # pragma: no cover
if any(isinstance(exc, e) for e in sentry_ignore_errors()):
return
- # Error may also be passed in from the loggingn context
+ # Error may also be passed in from the logging context
if hasattr(exc, 'event'):
event = getattr(exc, 'event', None)
diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py
index b1f0f5ce8084..663cf61f51f2 100644
--- a/src/backend/InvenTree/InvenTree/serializers.py
+++ b/src/backend/InvenTree/InvenTree/serializers.py
@@ -21,7 +21,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
@@ -1001,30 +1000,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'):
- request = self.context.get('request', None)
- method = getattr(request, 'method', None)
-
- if view := self.context.get('view', None):
- if (
- issubclass(view.__class__, ListModelMixin)
- and method in SAFE_METHODS
- 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'.
@@ -1131,19 +1106,13 @@ class DuplicateOptionsSerializer(serializers.Serializer):
'copy_parameters',
_('Copy Parameters'),
_('Copy parameters from the original item'),
- False,
- ),
- (
- 'copy_lines',
- _('Copy Lines'),
- _('Copy line items from the original order'),
- False,
),
+ ('copy_notes', _('Copy Notes'), _('Copy notes from the original item')),
+ ('copy_lines', _('Copy Lines'), _('Copy line items from the original order')),
(
'copy_extra_lines',
_('Copy Extra Lines'),
_('Copy extra line items from the original order'),
- False,
),
]
@@ -1177,8 +1146,8 @@ def __init__(
copy_field_names = [spec['name'] for spec in copy_fields]
# Apply "default" fields
- for name, label, help_text, default_value in self.DEFAULT_FIELDS:
- popped_value = kwargs.pop(name, default_value)
+ for name, label, help_text in self.DEFAULT_FIELDS:
+ popped_value = kwargs.pop(name, False)
if name in copy_field_names:
# Manually supplied field, continue
@@ -1217,3 +1186,34 @@ def __init__(
label=spec.get('label', spec['name']),
help_text=spec.get('help_text', ''),
)
+
+
+def apply_duplicate_copy_options(
+ instance, duplicate: dict, original, **copy_defaults: bool
+) -> None:
+ """Apply the standard 'copy_' duplicate-options onto a newly duplicated instance.
+
+ Many serializers which support duplication (Part/Company/ManufacturerPart/SupplierPart/
+ Build/PurchaseOrder/SalesOrder/ReturnOrder/TransferOrder/SalesOrderShipment) expose a set
+ of 'copy_' boolean flags (e.g. copy_notes, copy_parameters) which each map onto an
+ identically-named `instance.copy__from(original)` method. This is the shared dispatch
+ for that convention, so adding a new flag - e.g. a future copy_attachments, once
+ InvenTreeAttachmentMixin grows a copy_attachments_from() method - is a one-line addition
+ at each call site rather than a new copy-pasted `if duplicate.get(...): instance.copy_..._
+ from(...)` block. Any duplicate flag whose target method doesn't follow the
+ copy__from() naming convention (e.g. Part's copy_bom/copy_image/copy_tests, or
+ StockItem's copy_history/copy_tests) must still be handled separately by the caller.
+
+ Arguments:
+ instance: The newly created instance to copy data onto
+ duplicate: The validated 'duplicate' options dict - callers are expected to have
+ already checked `if duplicate:` before calling this (and extracted `original`
+ from it), since they still need both to handle their own additional flags
+ original: The source instance to copy data from
+ **copy_defaults: One kwarg per 'copy_' flag to apply, e.g.
+ `copy_notes=True, copy_parameters=True` - the kwarg's value is the default used
+ if that flag isn't present in `duplicate`
+ """
+ for flag, default in copy_defaults.items():
+ if duplicate.get(flag, default):
+ getattr(instance, f'{flag}_from')(original)
diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py
index c74632d4bc63..88661302d3d7 100644
--- a/src/backend/InvenTree/InvenTree/test_api.py
+++ b/src/backend/InvenTree/InvenTree/test_api.py
@@ -380,6 +380,9 @@ def test_results(self):
def test_search_filters(self):
"""Test that the regex, whole word, and notes filters are handled correctly."""
+ from build.models import Build
+ from common.models import Note
+
SEARCH_TERM = 'some note'
RE_SEARCH_TERM = 'some (.*) note'
@@ -388,10 +391,20 @@ def test_search_filters(self):
{'search': SEARCH_TERM, 'limit': 10, 'part': {}, 'build': {}},
expected_code=200,
)
+
# No build or part results
self.assertEqual(response.data['build']['count'], 0)
self.assertEqual(response.data['part']['count'], 0)
+ # Add a "note" to a build
+ build = Build.objects.first()
+
+ _note = Note.objects.create(
+ content='some note',
+ model_id=build.id,
+ model_type=build.get_content_type(),
+ )
+
# add the search_notes param
response = self.post(
reverse('api-search'),
@@ -404,8 +417,9 @@ def test_search_filters(self):
},
expected_code=200,
)
+
# now should have some build results
- self.assertEqual(response.data['build']['count'], 4)
+ self.assertEqual(response.data['build']['count'], 1)
# use the regex term
response = self.post(
@@ -436,7 +450,7 @@ def test_search_filters(self):
expected_code=200,
)
# we get our results back!
- self.assertEqual(response.data['build']['count'], 4)
+ self.assertEqual(response.data['build']['count'], 1)
# add the search_whole param
response = self.post(
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/0060_remove_build_notes.py b/src/backend/InvenTree/build/migrations/0060_remove_build_notes.py
new file mode 100644
index 000000000000..17be175e529f
--- /dev/null
+++ b/src/backend/InvenTree/build/migrations/0060_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", "0059_build_tags"),
+ ("common", "0052_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 ede56d0f612f..6e6ac39dd15d 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -87,8 +87,8 @@ class Build(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
+ InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
- InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
StatusCodeMixin,
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index 97edd4350ddf..e4aba183b629 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -41,9 +41,9 @@
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
- NotesFieldMixin,
OptionalField,
PrefetchSpec,
+ apply_duplicate_copy_options,
)
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
@@ -63,7 +63,6 @@
class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
- NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
@@ -105,7 +104,6 @@ class Meta:
'status_custom_key',
'target_date',
'take_from',
- 'notes',
'link',
'issued_by',
'issued_by_detail',
@@ -197,7 +195,9 @@ def annotate_queryset(queryset):
return queryset
- duplicate = DuplicateOptionsSerializer(Build.objects.all(), copy_parameters=True)
+ duplicate = DuplicateOptionsSerializer(
+ Build.objects.all(), copy_parameters=True, copy_notes=True
+ )
def __init__(self, *args, **kwargs):
"""Determine if extra serializer fields are required."""
@@ -213,10 +213,13 @@ def create(self, validated_data):
instance = super().create(validated_data)
if duplicate:
- original = duplicate['original']
-
- if duplicate.get('copy_parameters', True):
- instance.copy_parameters_from(original)
+ apply_duplicate_copy_options(
+ instance,
+ duplicate,
+ duplicate['original'],
+ copy_notes=True,
+ copy_parameters=True,
+ )
return instance
@@ -1558,12 +1561,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/build/test_api.py b/src/backend/InvenTree/build/test_api.py
index 7b7685b0ba0e..83fafc3b8ba4 100644
--- a/src/backend/InvenTree/build/test_api.py
+++ b/src/backend/InvenTree/build/test_api.py
@@ -11,6 +11,7 @@
from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus
+from common.models import Note
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
@@ -608,6 +609,62 @@ def test_create(self):
self.assertIsNotNone(bo.issued_by)
self.assertEqual(bo.issued_by, self.user)
+ def test_duplicate_copies_notes(self):
+ """Test that notes are copied when duplicating a Build via the API.
+
+ BuildSerializer declares its 'duplicate' options with copy_notes=True,
+ so notes should be copied by default (i.e. without explicitly requesting it).
+ """
+ from django.contrib.contenttypes.models import ContentType
+
+ url = reverse('api-build-list')
+
+ part = Part.objects.create(
+ name='Duplicate Notes Assembly', description='x', assembly=True
+ )
+
+ original = Build.objects.create(
+ part=part, reference='BO-9001', title='Original build', quantity=5
+ )
+
+ Note.objects.create(
+ model_type=ContentType.objects.get_for_model(Build),
+ model_id=original.pk,
+ title='Original Note',
+ content='Some build notes
',
+ )
+
+ response = self.post(
+ url,
+ {
+ 'reference': 'BO-9002',
+ 'part': part.pk,
+ 'quantity': 5,
+ 'title': 'Duplicate build',
+ 'duplicate': {'original': original.pk},
+ },
+ expected_code=201,
+ )
+
+ new_build = Build.objects.get(pk=response.data['pk'])
+ self.assertEqual(new_build.notes.count(), 1)
+ self.assertEqual(new_build.notes.first().content, 'Some build notes
')
+
+ # Explicitly disabling copy_notes must not copy any notes
+ response = self.post(
+ url,
+ {
+ 'reference': 'BO-9003',
+ 'part': part.pk,
+ 'quantity': 5,
+ 'title': 'Duplicate build without notes',
+ 'duplicate': {'original': original.pk, 'copy_notes': False},
+ },
+ expected_code=201,
+ )
+ no_notes_build = Build.objects.get(pk=response.data['pk'])
+ self.assertEqual(no_notes_build.notes.count(), 0)
+
class BuildAllocationTest(BuildAPITest):
"""Unit tests for allocation of stock items against a build order.
diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py
index 00db4a66d1fb..a85f5cfc9fd6 100644
--- a/src/backend/InvenTree/common/admin.py
+++ b/src/backend/InvenTree/common/admin.py
@@ -50,6 +50,15 @@ class SelectionListAdmin(admin.ModelAdmin):
inlines = [SelectionListEntryInlineAdmin]
+@admin.register(common.models.Note)
+class NoteAdmin(admin.ModelAdmin):
+ """Admin interface for Note objects."""
+
+ list_display = ('title', 'template', 'model_type', 'model_id', 'primary')
+ list_filter = ('template', 'model_type')
+ search_fields = ('title', 'description', 'content')
+
+
@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 885d3efd692e..7403755af4eb 100644
--- a/src/backend/InvenTree/common/api.py
+++ b/src/backend/InvenTree/common/api.py
@@ -466,17 +466,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."""
serializer.save(user=self.request.user)
@@ -870,6 +891,105 @@ 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', '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 by model type, allowing null for global templates."""
+ return common.filters.filter_content_type(
+ queryset, 'model_type', value, allow_null=True
+ )
+
+
+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]
+
+ def get_queryset(self):
+ """Filter notes to those the requesting user has view permission for.
+
+ Template notes (no attached model) are always visible.
+ Regular notes are only visible when the user has 'view' permission
+ for the model type the note is linked to.
+ """
+ import common.validators
+ from users.permissions import check_user_permission, prefetch_rule_sets
+
+ qs = super().get_queryset()
+ user = self.request.user
+
+ if user.is_superuser:
+ return qs
+
+ # Fetch the user's groups (with prefetched rule sets) once, and reuse it
+ # for every model type below - otherwise each check_user_permission()
+ # call re-fetches the same groups/rule-sets from scratch.
+ groups = prefetch_rule_sets(user)
+
+ allowed_ct_ids = [
+ ContentType.objects.get_for_model(model_class).pk
+ for model_class in common.validators.note_model_types()
+ if check_user_permission(user, model_class, 'view', groups=groups)
+ ]
+
+ return qs.filter(Q(template=True) | Q(model_type__in=allowed_ct_ids))
+
+
+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',
+ 'updated_by',
+ 'updated',
+ 'primary',
+ 'template',
+ 'title',
+ ]
+ search_fields = ['title', 'description', 'content']
+
+
+class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI):
+ """Detail API endpoint for Note objects."""
+
+ def perform_destroy(self, instance):
+ """Enforce the same permission rules on delete as on create/update.
+
+ DRF's default destroy() calls instance.delete() directly, bypassing
+ NoteSerializer.save() (and the permission checks it performs) entirely.
+ Without this, get_queryset()'s 'view' permission gate is all that
+ stands between a user and deleting the note.
+ """
+ common.serializers.check_note_change_permission(
+ self.request.user,
+ template=instance.template,
+ model_type=instance.model_type,
+ )
+ super().perform_destroy(instance)
+
+
class ParameterTemplateFilter(FilterSet):
"""FilterSet class for the ParameterTemplateList API endpoint."""
@@ -1191,6 +1311,79 @@ class ParameterDetail(ParameterMixin, RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Parameter objects."""
+class InstanceInfoView(APIView):
+ """Return aggregated attachment/note/parameter counts for a single model instance.
+
+ A single generic lookup (given a model_type + model_id) for any detail page to
+ drive its Attachments/Notes/Parameters tab notification dots from one request,
+ instead of each tab independently querying its own list endpoint just to read
+ a count.
+
+ Each count reuses the filtering (and, for notes, the view-permission gating)
+ already implemented by the corresponding list endpoint.
+ """
+
+ permission_classes = [IsAuthenticatedOrReadScope]
+
+ @extend_schema(
+ parameters=[
+ OpenApiParameter(name='model_type', type=str, required=True),
+ OpenApiParameter(name='model_id', type=int, required=True),
+ ],
+ responses={200: common.serializers.InstanceInfoSerializer},
+ )
+ def get(self, request, *args, **kwargs):
+ """Return counts of attachments, notes and parameters for the given instance."""
+ from InvenTree.models import (
+ InvenTreeAttachmentMixin,
+ InvenTreeNoteMixin,
+ InvenTreeParameterMixin,
+ )
+ from users.permissions import check_user_permission
+
+ model_type = request.query_params.get('model_type')
+ model_id = request.query_params.get('model_id')
+
+ if not model_type or not model_id:
+ raise ValidationError({
+ 'model_type': _('This field is required'),
+ 'model_id': _('This field is required'),
+ })
+
+ try:
+ model_id = int(model_id)
+ except (TypeError, ValueError):
+ raise ValidationError({'model_id': _('Invalid model ID')})
+
+ content_type = common.filters.determine_content_type(model_type)
+ model_class = content_type.model_class() if content_type else None
+
+ counts = {'attachment_count': 0, 'note_count': 0, 'parameter_count': 0}
+
+ if model_class:
+ if issubclass(model_class, InvenTreeAttachmentMixin):
+ counts['attachment_count'] = common.models.Attachment.objects.filter(
+ model_type=model_class.__name__.lower(), model_id=model_id
+ ).count()
+
+ if issubclass(model_class, InvenTreeNoteMixin):
+ user = request.user
+ if user.is_superuser or check_user_permission(
+ user, model_class, 'view'
+ ):
+ counts['note_count'] = common.models.Note.objects.filter(
+ model_type=content_type, model_id=model_id, template=False
+ ).count()
+
+ if issubclass(model_class, InvenTreeParameterMixin):
+ counts['parameter_count'] = common.models.Parameter.objects.filter(
+ model_type=content_type, model_id=model_id
+ ).count()
+
+ serializer = common.serializers.InstanceInfoSerializer(counts)
+ return Response(serializer.data)
+
+
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
class IconList(ListAPI):
"""List view for available icon packages."""
@@ -1498,8 +1691,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/',
@@ -1531,6 +1722,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/',
@@ -1566,6 +1773,8 @@ def create(self, request, *args, **kwargs):
path('', ParameterList.as_view(), name='api-parameter-list'),
]),
),
+ # Aggregated per-instance counts (attachments / notes / parameters)
+ path('instance-info/', InstanceInfoView.as_view(), name='api-instance-info'),
# Metadata
path(
'metadata/',
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/0050_note.py b/src/backend/InvenTree/common/migrations/0050_note.py
new file mode 100644
index 000000000000..c5382e7a6045
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0050_note.py
@@ -0,0 +1,149 @@
+# 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", "0049_notificationentry_charfield_uid"),
+ ("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(
+ blank=True,
+ null=True,
+ help_text="Target model instance ID for this note",
+ )),
+ (
+ "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",
+ help_text="Target model type for this note",
+ blank=True,
+ null=True,
+ validators=[common.validators.validate_note_model_type]
+ ),
+ ),
+ (
+ "template",
+ models.BooleanField(
+ default=False,
+ help_text="Is this note a template (not linked to a specific model instance)?",
+ verbose_name="Template",
+ ),
+ ),
+ (
+ "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',
+ ),
+ ),
+ # Add constraint to ensure that only one 'primary' note exists per model instance
+ migrations.AddConstraint(
+ model_name="note",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("primary", True), ("template", False)),
+ fields=("model_type", "model_id"),
+ name="unique_primary_note_per_model",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py b/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py
new file mode 100644
index 000000000000..6a05ed96f3c4
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py
@@ -0,0 +1,251 @@
+# Generated by Django 5.2.14 on 2026-05-25 09:56
+
+from tqdm import tqdm
+
+from django.db import migrations
+
+# Number of instances processed per Note.bulk_create() / NotesImage.bulk_update() call
+BATCH_SIZE = 500
+
+
+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 create_notes_batch(Note, NotesImage, content_type, model, instances, unlinked_images):
+ """Create Note objects (and link any associated images) for a batch of instances.
+
+ Issues a single bulk_create() for the notes, a single query + bulk_update() for
+ directly-linked images, and a single bulk_update() for images embedded in the
+ markdown content - instead of one Note.objects.create() and two image queries
+ *per instance*, which does not scale to tables with large numbers of notes.
+
+ `unlinked_images` is a shared list of not-yet-linked NotesImage objects, computed
+ once for the whole migration. Matched images are removed from it in place, so an
+ image can only ever be claimed by one note, matching the original per-instance
+ query's behaviour (each query only ever saw images not yet linked by a previous
+ instance).
+ """
+ notes = Note.objects.bulk_create(
+ [
+ Note(
+ title="Note", # We don't have a title field in the old model, so we'll just use a default value
+ content=markdown_to_html(instance.notes),
+ model_type=content_type,
+ model_id=instance.pk,
+ primary=True,
+ )
+ for instance in instances
+ ],
+ batch_size=BATCH_SIZE,
+ )
+
+ notes_by_model_id = {instance.pk: note for instance, note in zip(instances, notes)}
+
+ # Images directly linked to one of these instances
+ direct_images = list(
+ NotesImage.objects.filter(
+ model_type__iexact=model, model_id__in=list(notes_by_model_id)
+ )
+ )
+ for image in direct_images:
+ image.note = notes_by_model_id[image.model_id]
+
+ # Images not directly linked to any instance, but still referenced in the
+ # markdown content itself
+ embedded_images = []
+ for instance, note in zip(instances, notes):
+ matched = [
+ image for image in unlinked_images if image.image.url in instance.notes
+ ]
+ for image in matched:
+ image.note = note
+ embedded_images.append(image)
+ unlinked_images.remove(image)
+
+ updated_images = direct_images + embedded_images
+ if updated_images:
+ NotesImage.objects.bulk_update(updated_images, ['note'], batch_size=BATCH_SIZE)
+
+ return notes
+
+
+def migrate_orphaned_images(Note, NotesImage, content_type, model):
+ """Preserve any still-unlinked, directly-attached images for the given model.
+
+ create_notes_batch() only processes instances whose legacy 'notes' field is
+ non-empty (there's no note content to migrate for a blank one), so a directly
+ linked NotesImage (model_type/model_id set at upload time, independent of
+ whatever the 'notes' field currently contains) attached to a blank-notes
+ instance is never picked up by it and would otherwise be silently discarded
+ by remove_unlinked_images() at the end of this migration.
+
+ Rather than losing these images, create one empty, primary Note per affected
+ instance to hold them. The 'delete_old_notes_images' scheduled task (see
+ common.tasks) already handles cleaning up images which remain unreferenced
+ in their note's content once they age out - same as it always did before
+ this refactor - so nothing further needs to happen here.
+ """
+ orphaned_images = list(
+ NotesImage.objects.filter(model_type__iexact=model, note__isnull=True)
+ )
+
+ if not orphaned_images:
+ return
+
+ model_ids = sorted({image.model_id for image in orphaned_images})
+
+ notes = Note.objects.bulk_create(
+ [
+ Note(
+ title="Note",
+ content='',
+ model_type=content_type,
+ model_id=model_id,
+ primary=True,
+ )
+ for model_id in model_ids
+ ],
+ batch_size=BATCH_SIZE,
+ )
+
+ notes_by_model_id = dict(zip(model_ids, notes))
+
+ for image in orphaned_images:
+ image.note = notes_by_model_id[image.model_id]
+
+ NotesImage.objects.bulk_update(orphaned_images, ['note'], batch_size=BATCH_SIZE)
+
+
+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')
+
+ # Images not yet linked to any note, and not directly tied to a model instance -
+ # candidates for the "embedded in markdown content" match in create_notes_batch().
+ # Computed once for the whole migration (matched images are removed as they're
+ # claimed), rather than being re-queried and re-scanned from scratch for every
+ # single row being migrated.
+ unlinked_images = list(
+ NotesImage.objects.filter(note__isnull=True, model_id__isnull=True).exclude(
+ image__isnull=True
+ )
+ )
+
+ 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)
+
+ total = with_notes.count()
+
+ if total:
+ progress = tqdm(total=total, desc=f'Migration common.0051: Migrating notes for {app}.{model}')
+
+ created = 0
+ batch = []
+
+ for instance in with_notes.iterator(chunk_size=BATCH_SIZE):
+ batch.append(instance)
+
+ if len(batch) >= BATCH_SIZE:
+ created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images))
+ progress.update(len(batch))
+ batch = []
+
+ if batch:
+ created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images))
+ progress.update(len(batch))
+
+ if created != total:
+ raise RuntimeError(
+ f'Expected to create {total} notes for {app}.{model}, but created {created}.'
+ )
+
+ # Handle any remaining directly-linked images for instances with blank
+ # notes - not covered by the with_notes loop above, so this must run
+ # even when total == 0 (i.e. no instance of this model has any notes
+ # text at all, but some may still have directly-attached images).
+ migrate_orphaned_images(Note, NotesImage, content_type, model)
+
+
+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 = [
+ ("common", "0050_note"),
+ # Other internal apps which have models that support notes
+ ("build", "0059_build_tags"),
+ ("company", "0080_company_tags"),
+ ("order", "0121_add_line_item_discount"),
+ ("part", "0152_alter_partpricing_currency"),
+ ("stock", "0125_remove_mptt_fields")
+ ]
+
+ 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/0052_remove_notesimage_model_id_and_more.py b/src/backend/InvenTree/common/migrations/0052_remove_notesimage_model_id_and_more.py
new file mode 100644
index 000000000000..a0328f5d987f
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0052_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", "0051_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 321b45a3c35f..508b9087539d 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -4,11 +4,13 @@
"""
import base64
+import copy
import hashlib
import hmac
import json
import math
import os
+import re
import uuid
from collections import OrderedDict
from datetime import timedelta, timezone
@@ -42,6 +44,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
@@ -1786,42 +1789,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.
@@ -3047,7 +3014,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(
@@ -3097,6 +3063,310 @@ 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')
+
+ constraints = [
+ models.UniqueConstraint(
+ fields=['model_type', 'model_id'],
+ condition=models.Q(primary=True, template=False),
+ name='unique_primary_note_per_model',
+ )
+ ]
+
+ @staticmethod
+ def get_api_url() -> str:
+ """Return the API URL associated with the Parameter model."""
+ return reverse('api-note-list')
+
+ def validate_constraints(self, exclude=None):
+ """Validate model constraints, skipping 'unique_primary_note_per_model'.
+
+ That constraint is actively maintained by save() (which demotes any
+ sibling primary note before saving self), so checking it here against
+ pre-save DB state would incorrectly reject legitimate primary-flag
+ promotions that save() would otherwise handle correctly.
+ """
+ constraints = [
+ c
+ for c in self._meta.constraints
+ if c.name != 'unique_primary_note_per_model'
+ ]
+ errors = {}
+ for constraint in constraints:
+ try:
+ constraint.validate(self.__class__, self, exclude=exclude)
+ except ValidationError as e:
+ errors = e.update_error_dict(errors)
+ if errors:
+ raise ValidationError(errors)
+
+ @transaction.atomic
+ def save(self, *args, **kwargs):
+ """Perform custom save checks before saving a Note instance."""
+ self.check_save()
+
+ 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, set it as primary
+ if not siblings.exists():
+ self.primary = True
+
+ # Demote sibling notes *before* saving self, so that the partial unique
+ # constraint on (model_type, model_id, primary=True) is never briefly
+ # violated by two rows with primary=True existing at once
+ if self.primary:
+ siblings.update(primary=False)
+
+ self.clean()
+ super().save(*args, **kwargs)
+ 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.model_type:
+ try:
+ common.validators.validate_note_model_type(self.model_type)
+ except ValidationError as e:
+ raise ValidationError({'model_type': e.message})
+
+ 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',
+ },
+ )
+
+ # nh3 does not recognise legacy IE-only CSS expression() calls as
+ # unsafe, so they survive style attribute filtering - strip them explicitly
+ self.content = re.sub(
+ r'expression\s*\(', '', self.content, flags=re.IGNORECASE
+ )
+
+ def check_save(self):
+ """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:
+ 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
+
+ if self.template or not self.model_type:
+ return
+
+ try:
+ instance = self.content_object
+ except InvenTree.models.InvenTreeModel.DoesNotExist:
+ return
+
+ if instance and isinstance(instance, InvenTreeNoteMixin):
+ instance.check_note_delete(self)
+
+ def delete(self, *args, **kwargs):
+ """Perform custom delete checks before deleting a Note instance."""
+ self.check_delete()
+ super().delete(*args, **kwargs)
+
+ 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()
+
+ template = models.BooleanField(
+ default=False,
+ verbose_name=_('Template'),
+ help_text=_(
+ 'Is this note a template (not linked to a specific model instance)?'
+ ),
+ )
+
+ model_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ validators=[common.validators.validate_note_model_type],
+ help_text=_('Target model type for this note'),
+ )
+
+ model_id = models.PositiveIntegerField(
+ null=True, blank=True, help_text=_('Target model instance ID for this note')
+ )
+
+ 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 135539b7d865..7ccb76d04be8 100644
--- a/src/backend/InvenTree/common/serializers.py
+++ b/src/backend/InvenTree/common/serializers.py
@@ -22,7 +22,7 @@
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.mixins import DataImportExportSerializerMixin
-from InvenTree.models import InvenTreeParameterMixin
+from InvenTree.models import InvenTreeNoteMixin, InvenTreeParameterMixin
from InvenTree.serializers import (
ContentTypeField,
FilterableSerializerMixin,
@@ -404,7 +404,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']
@@ -825,7 +825,6 @@ def __init__(self, *args, **kwargs):
def save(self, **kwargs):
"""Override the save method to handle the model_type field."""
from InvenTree.models import InvenTreeAttachmentMixin
- from users.permissions import check_user_permission
model_type = self.validated_data.get('model_type', None)
@@ -839,21 +838,152 @@ def save(self, **kwargs):
model_type
)
- if not issubclass(target_model_class, InvenTreeAttachmentMixin):
- raise PermissionDenied(_('Invalid model type specified for attachment'))
+ check_model_change_permission(
+ user,
+ target_model_class,
+ InvenTreeAttachmentMixin,
+ _('Invalid model type specified for attachment'),
+ _(
+ 'User does not have permission to create or edit attachments for this model'
+ ),
+ )
+
+ return super().save(**kwargs)
+
+
+def check_model_change_permission(
+ user, target_model_class, mixin_class, invalid_model_msg, permission_error_msg
+):
+ """Ensure a user has 'change' permission against a generic-relation target model.
+
+ Shared by any serializer whose save() must verify both that the target model
+ supports a given mixin (e.g. Attachment/Parameter/Note), and that the user has
+ 'change' permission against it - the sequence of checks is identical in each
+ case; only the mixin class and the (separately translated, so callers keep
+ full-sentence translator context) error messages differ.
+
+ Raises PermissionDenied if the model class is invalid, or the user lacks
+ permission.
+ """
+ from users.permissions import check_user_permission
+
+ if not target_model_class or not issubclass(target_model_class, mixin_class):
+ raise PermissionDenied(invalid_model_msg)
+
+ 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)
+
+
+def check_note_change_permission(user, *, template, model_type):
+ """Check whether a user is permitted to create, edit or delete a note.
+
+ Shared between NoteSerializer.save() (create/update) and NoteDetail's
+ destroy handling (delete), so all three operations enforce the same rule:
+ staff-only for templates, model 'change' permission otherwise.
+
+ Raises PermissionDenied if the user is not permitted.
+ """
+ if template:
+ if not user.is_staff:
+ raise PermissionDenied(
+ _('Only staff users can create or edit note templates')
+ )
+ return
+
+ target_model_class = model_type.model_class() if model_type else None
+
+ check_model_change_permission(
+ user,
+ target_model_class,
+ InvenTreeNoteMixin,
+ _('Invalid model type specified for note'),
+ _('User does not have permission to create or edit notes for this model'),
+ )
+
+
+class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
+ """Serializer for the Note model."""
+
+ class Meta:
+ """Meta options for NoteSerializer."""
- permission_error_msg = _(
- 'User does not have permission to create or edit attachments for this model'
+ model = common_models.Note
+ fields = [
+ 'pk',
+ 'template',
+ 'model_type',
+ 'model_id',
+ 'primary',
+ 'title',
+ 'description',
+ 'content',
+ '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):
+ """Save the Note instance."""
+ user = self.context.get('request').user
+ is_template = self.validated_data.get(
+ 'template', getattr(self.instance, 'template', False)
+ )
+ model_type = self.validated_data.get('model_type') or (
+ self.instance and self.instance.model_type
)
- if not check_user_permission(user, target_model_class, 'change'):
- raise PermissionDenied(permission_error_msg)
+ check_note_change_permission(user, template=is_template, model_type=model_type)
- # Check that the user has the required permissions to attach files to the target model
- if not target_model_class.check_related_permission('change', user):
- raise PermissionDenied(permission_error_msg)
+ return super().save(updated_by=user, **kwargs)
- return super().save(**kwargs)
+ # Note: The choices are overridden at run-time on class initialization
+ model_type = ContentTypeField(
+ mixin_class=InvenTreeNoteMixin,
+ choices=common.validators.note_model_options,
+ label=_('Model Type'),
+ default=None,
+ allow_null=True,
+ required=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()
@@ -923,9 +1053,6 @@ class Meta:
def save(self, **kwargs):
"""Save the Parameter instance."""
- from InvenTree.models import InvenTreeParameterMixin
- from users.permissions import check_user_permission
-
model_type = self.validated_data.get('model_type', None)
if model_type is None and self.instance:
@@ -936,19 +1063,16 @@ def save(self, **kwargs):
target_model_class = model_type.model_class()
- if not issubclass(target_model_class, InvenTreeParameterMixin):
- raise PermissionDenied(_('Invalid model type specified for parameter'))
-
- permission_error_msg = _(
- 'User does not have permission to create or edit parameters for this model'
+ check_model_change_permission(
+ user,
+ target_model_class,
+ InvenTreeParameterMixin,
+ _('Invalid model type specified for parameter'),
+ _(
+ 'User does not have permission to create or edit parameters 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(updated_by=user, **kwargs)
return instance
@@ -1132,3 +1256,31 @@ class Meta:
fields = ['email']
email = serializers.EmailField(required=True)
+
+
+class InstanceInfoSerializer(serializers.Serializer):
+ """Serializer for aggregated per-instance counts (attachments, notes, parameters).
+
+ Backs a single generic lookup (see common.api.InstanceInfoView) that any
+ model instance's detail page can use to drive its Attachments/Notes/
+ Parameters tab notification dots from one request, instead of each tab
+ independently querying its own list endpoint just to read a count.
+ """
+
+ attachment_count = serializers.IntegerField(
+ label=_('Attachment Count'),
+ help_text=_('Number of attachments associated with this instance'),
+ read_only=True,
+ )
+
+ note_count = serializers.IntegerField(
+ label=_('Note Count'),
+ help_text=_('Number of notes associated with this instance'),
+ read_only=True,
+ )
+
+ parameter_count = serializers.IntegerField(
+ label=_('Parameter Count'),
+ help_text=_('Number of parameters associated with this instance'),
+ read_only=True,
+ )
diff --git a/src/backend/InvenTree/common/tasks.py b/src/backend/InvenTree/common/tasks.py
index 43afef5245cd..2a81c089f0b3 100644
--- a/src/backend/InvenTree/common/tasks.py
+++ b/src/backend/InvenTree/common/tasks.py
@@ -1,10 +1,10 @@
"""Tasks (processes that get offloaded) for common app."""
-import os
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
+from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError
from django.utils import timezone
@@ -15,8 +15,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__)
@@ -113,9 +111,16 @@ def update_news_feed():
@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.
+ """Remove old, unreferenced notes images from the database.
- Anything older than ~3 months is removed, unless it is linked to a note
+ Each NotesImage is linked to a specific Note via a required foreign key, so
+ (unlike the pre-refactor version of this task) we only need to check whether
+ the image is still referenced in *that one* note's content, rather than
+ searching every note-supporting model's table for a matching substring.
+
+ Anything older than ~3 months is removed, unless it is still referenced in
+ its associated note's content. Images whose file no longer exists in storage
+ are removed regardless of age, since there's nothing left to keep around.
"""
try:
from common.models import NotesImage
@@ -125,53 +130,28 @@ def delete_old_notes_images():
)
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()
+ # Remove any images whose file no longer exists in storage, regardless of
+ # age - there's nothing left to keep around
+ for image in NotesImage.objects.all():
+ if not image.image or not default_storage.exists(image.image.name):
+ logger.info(
+ 'delete_old_notes_images: Deleting image %s - file does not exist',
+ image.pk,
+ )
+ image.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
+ old_images = NotesImage.objects.filter(date__lte=before).select_related('note')
- 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))
+ for image in old_images:
+ if image.image.url not in image.note.content:
+ logger.info(
+ 'delete_old_notes_images: Deleting image %s - not referenced by note %s',
+ image.pk,
+ image.note.pk,
+ )
+ image.delete()
@tracer.start_as_current_span('rebuild_parameters')
diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py
index 71602098ca1f..d8d0a67a25d8 100644
--- a/src/backend/InvenTree/common/test_api.py
+++ b/src/backend/InvenTree/common/test_api.py
@@ -1063,6 +1063,661 @@ def test_attachments(self):
self.assertFalse(default_storage.exists(att.attachment.path))
+class InstanceInfoAPITests(InvenTreeAPITestCase):
+ """API tests for the InstanceInfoView (aggregated attachment/note/parameter counts)."""
+
+ roles = []
+
+ def setUp(self):
+ """Create a Part instance to query counts against."""
+ from part.models import Part
+
+ super().setUp()
+
+ self.part = Part.objects.create(name='Instance Info Test Part', description='x')
+
+ def _url(self, model_type=None, model_id=None):
+ params = {}
+ if model_type is not None:
+ params['model_type'] = model_type
+ if model_id is not None:
+ params['model_id'] = model_id
+ return reverse('api-instance-info'), params
+
+ def test_missing_params(self):
+ """Both model_type and model_id are required."""
+ url, _params = self._url()
+ response = self.get(url, expected_code=400)
+ self.assertIn('model_type', response.data)
+ self.assertIn('model_id', response.data)
+
+ def test_invalid_model_id(self):
+ """A non-numeric model_id is rejected."""
+ url, params = self._url('part', 'not-a-number')
+ response = self.get(url, data=params, expected_code=400)
+ self.assertIn('model_id', response.data)
+
+ def test_zero_counts(self):
+ """A part with no attachments/notes/parameters returns all zeros."""
+ url, params = self._url('part', self.part.pk)
+ response = self.get(url, data=params, expected_code=200)
+
+ self.assertEqual(response.data['attachment_count'], 0)
+ self.assertEqual(response.data['note_count'], 0)
+ self.assertEqual(response.data['parameter_count'], 0)
+
+ def test_nonexistent_model_type(self):
+ """An unsupported/unrecognized model_type returns all zeros, not an error."""
+ url, params = self._url('not_a_real_model', 1)
+ response = self.get(url, data=params, expected_code=200)
+
+ self.assertEqual(response.data['attachment_count'], 0)
+ self.assertEqual(response.data['note_count'], 0)
+ self.assertEqual(response.data['parameter_count'], 0)
+
+ def test_counts_reflect_related_objects(self):
+ """Counts reflect actual attachments/notes/parameters attached to the instance."""
+ from django.contrib.contenttypes.models import ContentType
+
+ from common.models import Note, Parameter, ParameterTemplate
+ from part.models import Part
+
+ # note_count requires 'view' permission on the target model (see
+ # test_note_count_respects_view_permission for that behaviour in isolation)
+ self.assignRole('part.view')
+
+ part_ct = ContentType.objects.get_for_model(Part)
+
+ common.models.Attachment.objects.create(
+ model_type='part',
+ model_id=self.part.pk,
+ link='https://example.com',
+ comment='test attachment',
+ )
+
+ Note.objects.create(
+ model_type=part_ct, model_id=self.part.pk, title='N', content='x
'
+ )
+
+ template = ParameterTemplate.objects.create(name='Colour')
+ Parameter.objects.create(
+ template=template, model_type=part_ct, model_id=self.part.pk, data='Red'
+ )
+
+ url, params = self._url('part', self.part.pk)
+ response = self.get(url, data=params, expected_code=200)
+
+ self.assertEqual(response.data['attachment_count'], 1)
+ self.assertEqual(response.data['note_count'], 1)
+ self.assertEqual(response.data['parameter_count'], 1)
+
+ def test_note_count_respects_view_permission(self):
+ """note_count is gated by 'view' permission on the target model, matching NoteList.
+
+ attachment_count / parameter_count are *not* gated (matching AttachmentList /
+ ParameterList, neither of which apply view-permission filtering today) - this
+ pins down that intentional asymmetry rather than accidentally widening or
+ narrowing either behaviour.
+ """
+ from django.contrib.contenttypes.models import ContentType
+
+ from common.models import Note, Parameter, ParameterTemplate
+ from part.models import Part
+
+ part_ct = ContentType.objects.get_for_model(Part)
+
+ common.models.Attachment.objects.create(
+ model_type='part',
+ model_id=self.part.pk,
+ link='https://example.com',
+ comment='test attachment',
+ )
+ Note.objects.create(
+ model_type=part_ct, model_id=self.part.pk, title='N', content='x
'
+ )
+ template = ParameterTemplate.objects.create(name='Colour')
+ Parameter.objects.create(
+ template=template, model_type=part_ct, model_id=self.part.pk, data='Red'
+ )
+
+ # No roles assigned - user cannot view Part notes
+ url, params = self._url('part', self.part.pk)
+ response = self.get(url, data=params, expected_code=200)
+
+ self.assertEqual(response.data['attachment_count'], 1)
+ self.assertEqual(response.data['note_count'], 0)
+ self.assertEqual(response.data['parameter_count'], 1)
+
+ # Once granted view permission, the note becomes visible too
+ self.assignRole('part.view')
+ response = self.get(url, data=params, expected_code=200)
+ self.assertEqual(response.data['note_count'], 1)
+
+
+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 NoteModelTypeValidationTests(InvenTreeAPITestCase):
+ """Tests that Note.model_type is restricted to models which support notes.
+
+ Covers both the model-level validator (common.validators.validate_note_model_type,
+ attached via Note.model_type's `validators` and invoked explicitly in Note.clean(),
+ so it applies to any code path - not just the DRF serializer) and the API-level
+ check (ContentTypeField(mixin_class=InvenTreeNoteMixin, ...)) - both derive from
+ the same InvenTreeNoteMixin-based lookup, rather than maintaining separate lists.
+ """
+
+ def test_model_rejects_unsupported_content_type(self):
+ """Note.full_clean() rejects a content type which does not support notes."""
+ from django.contrib.auth import get_user_model
+ from django.contrib.contenttypes.models import ContentType
+ from django.core.exceptions import ValidationError
+
+ from common.models import Note
+
+ user_ct = ContentType.objects.get_for_model(get_user_model())
+
+ note = Note(model_type=user_ct, model_id=1, title='Bad Note')
+
+ with self.assertRaises(ValidationError) as cm:
+ note.full_clean()
+ self.assertIn('model_type', cm.exception.message_dict)
+
+ def test_save_rejects_unsupported_content_type(self):
+ """Note.save() rejects a content type which does not support notes.
+
+ Note.clean() explicitly invokes the shared validator, so this is caught
+ even when full_clean()/clean_fields() is never called - e.g. direct
+ Note.objects.create() calls from the admin, shell, or other app code.
+ """
+ from django.contrib.auth import get_user_model
+ from django.contrib.contenttypes.models import ContentType
+ from django.core.exceptions import ValidationError
+
+ from common.models import Note
+
+ user_ct = ContentType.objects.get_for_model(get_user_model())
+
+ with self.assertRaises(ValidationError):
+ Note.objects.create(model_type=user_ct, model_id=1, title='Bad Note')
+
+ self.assertFalse(Note.objects.filter(title='Bad Note').exists())
+
+ def test_model_accepts_supported_content_type(self):
+ """Note.full_clean() accepts a content type which does support notes."""
+ from django.contrib.contenttypes.models import ContentType
+
+ from common.models import Note
+ from part.models import Part
+
+ part_ct = ContentType.objects.get_for_model(Part)
+
+ note = Note(model_type=part_ct, model_id=1, title='Good Note')
+ note.full_clean()
+
+ def test_api_rejects_unsupported_content_type(self):
+ """The Note API rejects a model_type which does not support notes."""
+ self.assignRole('part.change')
+
+ response = self.post(
+ reverse('api-note-list'),
+ data={'model_type': 'auth.user', 'model_id': 1, 'title': 'Bad Note'},
+ expected_code=400,
+ )
+ self.assertIn('model_type', response.data)
+
+
+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('hello
"
+ )
+ content = response.data['content']
+ self.assertNotIn('")
+ self.assertNotIn('")
+ self.assertNotIn('">'
+ )
+ content = response.data['content']
+ self.assertNotIn('Updated
"},
+ expected_code=200,
+ )
+ content = response.data['content']
+ self.assertNotIn('