This commit is contained in:
Oliver 2026-07-06 17:05:26 +10:00 committed by GitHub
commit 77a6675cfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 4204 additions and 994 deletions

View File

@ -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-notes-migration # 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-notes-migration # TODO: Fix this before merging
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract

View File

@ -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.
- [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04.
### Added

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

147
docs/docs/concepts/notes.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -979,6 +979,95 @@ 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 %}
<!-- Render the primary note for the part -->
{% note part as part_note %}
<div>{{ part_note }}</div>
<!-- Render a note by title -->
{% note part "Assembly Instructions" as instructions %}
<div>{{ instructions }}</div>
{% 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 %}
<h3>{{ primary_note.title }}</h3>
{% if primary_note.description %}<p><em>{{ primary_note.description }}</em></p>{% endif %}
{% note part as note_content %}
<div>{{ note_content }}</div>
{% 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 %}
<h3>{{ n.title }}</h3>
{% note part n.title as note_content %}
<div>{{ note_content }}</div>
{% endfor %}
{% endraw %}
```
!!! tip "Ordering Notes"
Use the [`order_queryset`](#order_queryset) helper to control the order in which notes are rendered, for example `{% order_queryset part.notes.all 'title' as ordered_notes %}`.
## 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.

View File

@ -102,6 +102,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

View File

@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 515
INVENTREE_API_VERSION = 516
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v516 -> 2026-07-03 : 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
v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298
- Change the file fields definition to binary (from uri) in the upload requests

View File

@ -100,11 +100,12 @@ class InvenTreeConfig(AppConfig):
def remove_obsolete_tasks(self):
"""Delete any obsolete scheduled tasks in the database."""
obsolete = [
'common.tasks.delete_old_notes_images',
'data_exporter.tasks.cleanup_old_export_outputs',
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
'report.tasks.cleanup_old_report_outputs',
'data_exporter.tasks.cleanup_old_export_outputs',
'stock.tasks.delete_old_stock_items',
]
try:

View File

@ -29,12 +29,6 @@ from PIL import Image
from stdimage.models import StdImageField, StdImageFieldFile
from common.currency import currency_code_default
from InvenTree.sanitizer import (
DEAFAULT_ATTRS,
DEFAULT_CSS,
DEFAULT_PROTOCOLS,
DEFAULT_TAGS,
)
logger = structlog.get_logger('inventree')
@ -939,63 +933,6 @@ def remove_non_printable_characters(value: str, remove_newline=True) -> str:
return cleaned
def clean_markdown(value: str) -> str:
"""Clean a markdown string.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
try:
markdownify_settings = settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
markdownify_settings = {}
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
# Generate raw HTML from provided markdown (without sanitizing)
# Note: The 'html' output_format is required to generate self closing tags, e.g. <tag> instead of <tag />
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.

View File

@ -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 @@ class CleanMixin:
"""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:

View File

@ -29,7 +29,6 @@ from taggit.managers import TaggableManager
import common.settings
import InvenTree.exceptions
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
@ -648,14 +647,14 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
return params
def check_parameter_delete(self, parameter):
def check_parameter_delete(self, parameter) -> bool:
"""Run a check to determine if the provided parameter can be deleted.
The default implementation always returns True, but this can be overridden in the implementing class.
"""
return True
def check_parameter_save(self, parameter):
def check_parameter_save(self, parameter) -> bool:
"""Run a check to determine if the provided parameter can be saved.
The default implementation always returns True, but this can be overridden in the implementing class.
@ -663,6 +662,130 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
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__)
# Sort so primary note is saved last — Note.save() promotes the last
# note saved with primary=True, which correctly mirrors the source.
source_notes = sorted(other.notes.all(), 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()
content_updated = False
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)
new_img.image.save(filename, ContentFile(data), save=True)
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.
@ -1207,51 +1330,6 @@ class PathStringMixin(models.Model):
]
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.

View File

@ -244,7 +244,7 @@ ALLOWED_ATTRIBUTES_SVG = [
]
# 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 @@ DEFAULT_TAGS = frozenset([
'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',

View File

@ -20,7 +20,6 @@ from drf_spectacular.utils import extend_schema_field
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
@ -810,26 +809,6 @@ class CustomStatusSerializerMixin(serializers.Serializer):
)
class NotesFieldMixin:
"""Serializer mixin for handling 'notes' fields.
The 'notes' field will be hidden in a LIST serializer,
but available in a DETAIL serializer.
"""
def __init__(self, *args, **kwargs):
"""Remove 'notes' field from list views."""
super().__init__(*args, **kwargs)
if hasattr(self, 'context'):
if view := self.context.get('view', None):
if (
issubclass(view.__class__, ListModelMixin)
and not InvenTree.ready.isGeneratingSchema()
):
self.fields.pop('notes', None)
class ContentTypeField(serializers.ChoiceField):
"""Serializer field which represents a ContentType as 'app_label.model_name'.
@ -936,19 +915,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,
),
]
@ -982,8 +955,8 @@ class DuplicateOptionsSerializer(serializers.Serializer):
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

View File

@ -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

View File

@ -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", "0049_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="build",
name="notes",
),
]

View File

@ -76,8 +76,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,

View File

@ -39,7 +39,6 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
from stock.generators import generate_batch_code
@ -60,7 +59,6 @@ from .validators import check_build_output
class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
@ -102,7 +100,6 @@ class BuildSerializer(
'status_custom_key',
'target_date',
'take_from',
'notes',
'link',
'issued_by',
'issued_by_detail',
@ -194,7 +191,9 @@ class BuildSerializer(
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."""
@ -215,6 +214,9 @@ class BuildSerializer(
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
if duplicate.get('copy_notes', True):
instance.copy_notes_from(original)
return instance
def validate_reference(self, reference):
@ -1540,12 +1542,9 @@ class BuildLineSerializer(
# 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',
)

View File

@ -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."""

View File

@ -466,17 +466,38 @@ class ConfigViewSet(viewsets.ReadOnlyModelViewSet):
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,86 @@ class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
return super().destroy(request, *args, **kwargs)
class NoteFilter(FilterSet):
"""Filterset class for the NoteList API endpoint."""
class Meta:
"""Metaclass options for the filterset."""
model = common.models.Note
fields = ['model_type', 'model_id', 'updated_by', '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.
"""
from InvenTree.models import InvenTreeNoteMixin
from users.permissions import check_user_permission
qs = super().get_queryset()
user = self.request.user
if user.is_superuser:
return qs
allowed_ct_ids = []
for ct in ContentType.objects.all():
model_class = ct.model_class()
if model_class and issubclass(model_class, InvenTreeNoteMixin):
if check_user_permission(user, model_class, 'view'):
allowed_ct_ids.append(ct.pk)
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."""
class ParameterTemplateFilter(FilterSet):
"""FilterSet class for the ParameterTemplateList API endpoint."""
@ -1498,8 +1599,6 @@ settings_api_urls = [
common_api_urls = [
# Webhooks
path('webhook/<slug:endpoint>/', 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 +1630,22 @@ common_api_urls = [
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(
'<int:pk>/',
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/',

View File

@ -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),
),
]

View File

@ -0,0 +1,148 @@
# 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", "0046_alter_emailmessage_global_id_and_more"),
("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,
),
),
(
"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",
),
),
]

View File

@ -0,0 +1,142 @@
# Generated by Django 5.2.14 on 2026-05-25 09:56
from tqdm import tqdm
from django.db import migrations
def get_markdownify_settings() -> dict:
"""Return the settings for markdownify, or an empty dict if not defined."""
from django.conf import settings
try:
return settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
return {}
def markdown_to_html(value: str) -> str:
"""Convert a markdown string to HTML.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
markdownify_settings = get_markdownify_settings()
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
html = markdown.markdown(
value or '',
extensions=extensions,
extension_configs=extension_configs,
output_format='html',
)
return html
def migrate_notes(apps, schema_editor):
"""Migrate existing notes to the new Note model."""
ContentType = apps.get_model("contenttypes", "ContentType")
# New target models
Note = apps.get_model('common', 'Note')
NotesImage = apps.get_model('common', 'NotesImage')
for app, model in [
('build', 'build'),
('company', 'company'),
('company', 'manufacturerpart'),
('company', 'supplierpart'),
('order', 'purchaseorder'),
('order', 'returnorder'),
('order', 'salesorder'),
('order', 'salesordershipment'),
('order', 'transferorder'),
('part', 'part'),
('stock', 'stockitem'),
]:
# Find old model which contains the 'notes' field
OldModel = apps.get_model(app, model)
with_notes = OldModel.objects.exclude(notes__isnull=True).exclude(notes='')
content_type, _created = ContentType.objects.get_or_create(app_label=app, model=model)
if not with_notes.exists():
continue
progress = tqdm(total=with_notes.count(), desc=f'Migration common.0045: Migrating notes for {app}.{model}')
initial_note_count = Note.objects.count()
expected_note_count = initial_note_count + with_notes.count()
for instance in with_notes:
content = markdown_to_html(instance.notes)
note = Note.objects.create(
title="Note", # We don't have a title field in the old model, so we'll just use a default value
content=content,
model_type=content_type,
model_id=instance.pk,
primary=True
)
# Find any existing NotesImage objects associated with this instance
images = NotesImage.objects.filter(model_type__iexact=model, model_id=instance.pk)
images.update(note=note)
# Find any notes images which do not directly reference the model instance, but are still referenced in the markdown content
unlinked_images = NotesImage.objects.filter(
note__isnull=True,
model_id__isnull=True
).exclude(
image__isnull=True
)
for image in unlinked_images:
# If the image is referenced in the markdown content, link it to the note
if image.image.url in instance.notes:
image.note = note
image.save()
progress.update(1)
assert Note.objects.count() == expected_note_count, f"Expected {expected_note_count} notes, but found {Note.objects.count()} after migration."
def remove_unlinked_images(apps, schema_editor):
"""Remove any NoteImage objects which are not linked to a Note instance."""
NotesImage = apps.get_model('common', 'NotesImage')
unlinked_images = NotesImage.objects.filter(note__isnull=True)
for image in unlinked_images:
image.delete()
class Migration(migrations.Migration):
# Ensure that each app which supports 'notes' is up-to-date first
dependencies = [
("build", "0059_build_tags"),
("common", "0047_note"),
("company", "0079_auto_20260212_1054"),
("order", "0119_transferorderlineitem_line_int"),
("part", "0150_part_maximum_stock"),
("stock", "0123_remove_stockitem_review_needed")
]
operations = [
migrations.RunPython(
code=migrate_notes,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=remove_unlinked_images,
reverse_code=migrations.RunPython.noop,
)
]

View File

@ -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", "0048_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",
),
),
]

View File

@ -4,6 +4,7 @@ These models are 'generic' and do not fit a particular business logic object.
"""
import base64
import copy
import hashlib
import hmac
import json
@ -42,6 +43,7 @@ from django.urls import reverse
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
@ -1771,42 +1773,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.
@ -2968,7 +2934,6 @@ class Parameter(
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(
@ -3018,6 +2983,273 @@ class Parameter(
return self.template.description
class Note(
UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
):
"""Class which represents a note assigned to a particular model instance.
Attributes:
model_type: The type of model to which this note is linked
model_id: The ID of the model to which this note is linked
user: The user who created the note
title: The title of the note
description: A description of the note (optional)
content: The content of the note
created: Date/time that the note was created
"""
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')
@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
self.clean()
super().save(*args, **kwargs)
# Mark other notes as non-primary
if self.primary:
siblings.update(primary=False)
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.content:
attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES)
for tag in (
'span',
'p',
'div',
'img',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'colgroup',
'col',
):
attrs.setdefault(tag, set()).update({'style'})
# Allow class on structural tags used by the rich-text editor
for tag in ('div', 'span', 'img', 'table', 'td', 'th', 'col'):
attrs.setdefault(tag, set()).add('class')
# Allow image attributes used by tiptap-extension-resizable-image
attrs.setdefault('img', set()).update({'data-keep-ratio', 'colwidth'})
self.content = nh3.clean(
self.content.strip(),
attributes=attrs,
filter_style_properties={
'color',
'background-color',
'font-size',
'font-weight',
'font-style',
'font-family',
'text-decoration',
'text-align',
'border',
'border-color',
'border-style',
'border-width',
'margin',
'padding',
'column-width',
'column-height',
'min-width',
'max-width',
'min-height',
'max-height',
'width',
'height',
},
)
def check_save(self):
"""Check if this note can be saved."""
from InvenTree.models import InvenTreeNoteMixin
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,
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."""

View File

@ -22,7 +22,7 @@ from importer.registry import register_importer
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,
@ -402,7 +402,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
"""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']
@ -854,6 +854,111 @@ class AttachmentSerializer(
return super().save(**kwargs)
class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Note model."""
class Meta:
"""Meta options for NoteSerializer."""
model = common_models.Note
fields = [
'pk',
'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."""
from users.permissions import check_user_permission
user = self.context.get('request').user
is_template = self.validated_data.get(
'template', getattr(self.instance, 'template', False)
)
if is_template:
if not user.is_staff:
raise PermissionDenied(
_('Only staff users can create or edit note templates')
)
return super().save(updated_by=user, **kwargs)
model_type = self.validated_data.get('model_type') or (
self.instance and self.instance.model_type
)
target_model_class = model_type.model_class()
if not issubclass(target_model_class, InvenTreeNoteMixin):
raise PermissionDenied(_('Invalid model type specified for note'))
permission_error_msg = _(
'User does not have permission to create or edit notes for this model'
)
if not check_user_permission(user, target_model_class, 'change'):
raise PermissionDenied(permission_error_msg)
if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg)
return super().save(updated_by=user, **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()
class ParameterTemplateSerializer(
DataImportExportSerializerMixin, InvenTreeModelSerializer

View File

@ -1,6 +1,5 @@
"""Tasks (processes that get offloaded) for common app."""
import os
from datetime import timedelta
from django.conf import settings
@ -15,8 +14,6 @@ from opentelemetry import trace
import common.models
import InvenTree.helpers
from InvenTree.helpers_model import getModelsWithMixin
from InvenTree.models import InvenTreeNotesMixin
from InvenTree.tasks import ScheduledTask, scheduled_task
tracer = trace.get_tracer(__name__)
@ -110,70 +107,6 @@ def update_news_feed():
logger.info('update_news_feed: Sync done')
@tracer.start_as_current_span('delete_old_notes_images')
@scheduled_task(ScheduledTask.DAILY)
def delete_old_notes_images():
"""Remove old notes images from the database.
Anything older than ~3 months is removed, unless it is linked to a note
"""
try:
from common.models import NotesImage
except AppRegistryNotReady:
logger.info(
"Could not perform 'delete_old_notes_images' - App registry not ready"
)
return
# Remove any notes which point to non-existent image files
for note in NotesImage.objects.all():
if not os.path.exists(note.image.path):
logger.info('Deleting note %s - image file does not exist', note.image.path)
note.delete()
note_classes = getModelsWithMixin(InvenTreeNotesMixin)
before = InvenTree.helpers.current_date() - timedelta(days=90)
for note in NotesImage.objects.filter(date__lte=before):
# Find any images which are no longer referenced by a note
found = False
img = note.image.name
for model in note_classes:
if model.objects.filter(notes__icontains=img).exists():
found = True
break
if not found:
logger.info('Deleting note %s - image file not linked to a note', img)
note.delete()
# Finally, remove any images in the notes dir which are not linked to a note
notes_dir = os.path.join(settings.MEDIA_ROOT, 'notes')
try:
images = os.listdir(notes_dir)
except FileNotFoundError:
# Thrown if the directory does not exist
images = []
all_notes = NotesImage.objects.all()
for image in images:
found = False
for note in all_notes:
img_path = os.path.basename(note.image.path)
if img_path == image:
found = True
break
if not found:
logger.info('Deleting note %s - image file not linked to a note', image)
os.remove(os.path.join(notes_dir, image))
@tracer.start_as_current_span('rebuild_parameters')
def rebuild_parameters(template_id):
"""Rebuild all parameters for a given template.

View File

@ -796,6 +796,460 @@ class AttachmentAPITests(InvenTreeAPITestCase):
self.assertFalse(default_storage.exists(att.attachment.path))
class NoteAPITests(InvenTreeAPITestCase):
"""API tests for the Note model, focusing on the 'primary' flag behaviour."""
def setUp(self):
"""Create a Part instance to attach notes to."""
from part.models import Part
super().setUp()
self.assignRole('part.add')
self.part = Part.objects.create(
name='Test Part', description='A part for testing notes'
)
def _note_url(self, pk=None):
if pk:
return reverse('api-note-detail', kwargs={'pk': pk})
return reverse('api-note-list')
def _create_note(self, title, primary=None, expected_code=201):
data = {'model_type': 'part', 'model_id': self.part.pk, 'title': title}
if primary is not None:
data['primary'] = primary
return self.post(self._note_url(), data=data, expected_code=expected_code)
def test_first_note_is_primary(self):
"""A note created when no other notes exist is automatically primary."""
response = self._create_note('Only Note')
self.assertTrue(response.data['primary'])
def test_second_note_not_primary_by_default(self):
"""Notes created after the first are not primary by default."""
first = self._create_note('First Note')
second = self._create_note('Second Note')
self.assertTrue(first.data['primary'])
self.assertFalse(second.data['primary'])
# Confirm the first is still marked primary in the database
from common.models import Note
self.assertTrue(Note.objects.get(pk=first.data['pk']).primary)
def test_setting_primary_clears_others(self):
"""Marking a note as primary demotes all sibling notes."""
first = self._create_note('First Note')
second = self._create_note('Second Note')
third = self._create_note('Third Note')
# Only the first should be primary after creation
self.assertTrue(first.data['primary'])
self.assertFalse(second.data['primary'])
self.assertFalse(third.data['primary'])
# Promote the third note via PATCH
response = self.patch(
self._note_url(third.data['pk']), data={'primary': True}, expected_code=200
)
self.assertTrue(response.data['primary'])
# Verify via the list endpoint that only the third is primary
list_response = self.get(
self._note_url(),
data={'model_type': 'part', 'model_id': self.part.pk},
expected_code=200,
)
primary_pks = [n['pk'] for n in list_response.data if n['primary']]
self.assertEqual(primary_pks, [third.data['pk']])
def test_primary_flag_isolated_per_model_instance(self):
"""Primary flag changes on one model instance do not affect notes on another."""
from part.models import Part
other_part = Part.objects.create(name='Other Part', description='Another part')
note_a = self._create_note('Note on Part A')
self.assertTrue(note_a.data['primary'])
# Create a note on the other part; it should be primary for *that* part
note_b_response = self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': other_part.pk,
'title': 'Note on Part B',
},
expected_code=201,
)
self.assertTrue(note_b_response.data['primary'])
# The note on Part A should still be primary
note_a_detail = self.get(self._note_url(note_a.data['pk']), expected_code=200)
self.assertTrue(note_a_detail.data['primary'])
class NoteContentSanitizationTests(InvenTreeAPITestCase):
"""Security tests for the Note API 'content' field.
The content field accepts raw HTML which is sanitized by nh3 before
persistence. These tests verify that known XSS vectors are neutralised
both at the model level (Note.clean()) and through the API (POST/PATCH).
"""
def setUp(self):
"""Create a Part instance to attach notes to."""
from part.models import Part
super().setUp()
self.assignRole('part.add')
self.part = Part.objects.create(
name='Security Test Part', description='Part for note security testing'
)
def _note_url(self, pk=None):
if pk:
return reverse('api-note-detail', kwargs={'pk': pk})
return reverse('api-note-list')
def _create_note_with_content(self, content, expected_code=201):
return self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': self.part.pk,
'title': 'Security Test Note',
'content': content,
},
expected_code=expected_code,
)
# -------------------------------------------------------------------------
# Model-level sanitization (Note.clean() called directly)
# -------------------------------------------------------------------------
def test_model_clean_strips_script_tags(self):
"""Note.clean() removes <script> tags, preserving safe surrounding content."""
from django.contrib.contenttypes.models import ContentType
from common.models import Note
ct = ContentType.objects.get_for_model(self.part.__class__)
note = Note(
model_type=ct,
model_id=self.part.pk,
title='Model-level test',
content="<script>alert('xss')</script><p>Safe content</p>",
)
note.clean()
self.assertNotIn('<script', note.content.lower())
self.assertIn('Safe content', note.content)
def test_model_clean_strips_event_handlers(self):
"""Note.clean() strips inline event-handler attributes from allowed tags."""
from django.contrib.contenttypes.models import ContentType
from common.models import Note
ct = ContentType.objects.get_for_model(self.part.__class__)
note = Note(
model_type=ct,
model_id=self.part.pk,
title='Event handler test',
content='<p onclick="alert(\'xss\')">text</p>',
)
note.clean()
self.assertNotIn('onclick', note.content.lower())
self.assertIn('text', note.content)
def test_model_clean_strips_javascript_protocol(self):
"""Note.clean() removes javascript: from href attributes."""
from django.contrib.contenttypes.models import ContentType
from common.models import Note
ct = ContentType.objects.get_for_model(self.part.__class__)
note = Note(
model_type=ct,
model_id=self.part.pk,
title='Protocol test',
content='<a href="javascript:alert(\'xss\')">link</a>',
)
note.clean()
self.assertNotIn('javascript:', note.content.lower())
# -------------------------------------------------------------------------
# API - script injection (POST)
# -------------------------------------------------------------------------
def test_api_script_tag_stripped(self):
"""<script> tags are stripped when content is submitted via the API."""
response = self._create_note_with_content(
"<script>alert('xss')</script><p>hello</p>"
)
content = response.data['content']
self.assertNotIn('<script', content.lower())
self.assertIn('hello', content)
def test_api_uppercase_script_stripped(self):
"""Uppercase <SCRIPT> tags are stripped."""
response = self._create_note_with_content("<SCRIPT>alert('xss')</SCRIPT>")
self.assertNotIn('<script', response.data['content'].lower())
def test_api_mixed_case_script_stripped(self):
"""Mixed-case <ScRiPt> tags are stripped."""
response = self._create_note_with_content("<ScRiPt>alert('xss')</ScRiPt>")
self.assertNotIn('<script', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - event handler injection
# -------------------------------------------------------------------------
def test_api_onerror_handler_stripped(self):
"""Onerror attribute is stripped from img tags."""
response = self._create_note_with_content("<img src='x' onerror='alert(1)'>")
self.assertNotIn('onerror', response.data['content'].lower())
def test_api_onload_handler_stripped(self):
"""Onload attribute is stripped (e.g. on svg tags)."""
response = self._create_note_with_content(
"<svg onload='alert(1)'><rect/></svg>"
)
self.assertNotIn('onload', response.data['content'].lower())
def test_api_onclick_handler_stripped(self):
"""Onclick attribute is stripped from otherwise-allowed tags."""
response = self._create_note_with_content("<p onclick='alert(1)'>click me</p>")
self.assertNotIn('onclick', response.data['content'].lower())
def test_api_onmouseover_handler_stripped(self):
"""Onmouseover attribute is stripped."""
response = self._create_note_with_content("<a onmouseover='alert(1)'>hover</a>")
self.assertNotIn('onmouseover', response.data['content'].lower())
def test_api_onfocus_handler_stripped(self):
"""Onfocus attribute on an input element is stripped."""
response = self._create_note_with_content(
"<input onfocus='alert(1)' autofocus>"
)
self.assertNotIn('onfocus', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - javascript: / vbscript: protocol injection
# -------------------------------------------------------------------------
def test_api_javascript_href_stripped(self):
"""javascript: href is removed from anchor tags."""
response = self._create_note_with_content(
"<a href='javascript:alert(1)'>click</a>"
)
self.assertNotIn('javascript:', response.data['content'].lower())
def test_api_javascript_href_uppercase_stripped(self):
"""JAVASCRIPT: href (uppercase) is removed from anchor tags."""
response = self._create_note_with_content(
"<a href='JAVASCRIPT:alert(1)'>click</a>"
)
self.assertNotIn('javascript:', response.data['content'].lower())
def test_api_vbscript_href_stripped(self):
"""vbscript: href is removed from anchor tags."""
response = self._create_note_with_content(
"<a href='vbscript:msgbox(1)'>click</a>"
)
self.assertNotIn('vbscript:', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - dangerous tag removal
# -------------------------------------------------------------------------
def test_api_iframe_stripped(self):
"""<iframe> tags are stripped entirely."""
response = self._create_note_with_content(
"<iframe src='https://evil.example.com'></iframe>"
)
self.assertNotIn('<iframe', response.data['content'].lower())
def test_api_object_stripped(self):
"""<object> tags are stripped entirely."""
response = self._create_note_with_content("<object data='evil.swf'></object>")
self.assertNotIn('<object', response.data['content'].lower())
def test_api_embed_stripped(self):
"""<embed> tags are stripped entirely."""
response = self._create_note_with_content("<embed src='evil.swf'>")
self.assertNotIn('<embed', response.data['content'].lower())
def test_api_base_tag_stripped(self):
"""<base> tags are stripped (prevents base-URL hijacking)."""
response = self._create_note_with_content(
"<base href='https://evil.example.com'>"
)
self.assertNotIn('<base', response.data['content'].lower())
def test_api_link_tag_stripped(self):
"""<link> tags are stripped (prevents external stylesheet injection)."""
response = self._create_note_with_content(
"<link rel='stylesheet' href='evil.css'>"
)
self.assertNotIn('<link', response.data['content'].lower())
def test_api_meta_refresh_stripped(self):
"""<meta http-equiv=refresh> tags are stripped."""
response = self._create_note_with_content(
"<meta http-equiv='refresh' content='0;url=https://evil.example.com'>"
)
self.assertNotIn('<meta', response.data['content'].lower())
def test_api_form_stripped(self):
"""<form> tags are stripped (prevents CSRF / phishing via injected forms)."""
response = self._create_note_with_content(
"<form action='https://evil.example.com'><input name='x'></form>"
)
self.assertNotIn('<form', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - CSS / style injection
# -------------------------------------------------------------------------
def test_api_style_attribute_javascript_stripped(self):
"""Style attributes containing javascript: expressions are stripped."""
response = self._create_note_with_content(
"<div style='background:url(javascript:alert(1))'>x</div>"
)
self.assertNotIn('javascript:', response.data['content'].lower())
def test_api_style_expression_stripped(self):
"""IE-era CSS expression() is stripped from style attributes."""
response = self._create_note_with_content(
'<p style="width:expression(alert(\'xss\'))">x</p>'
)
self.assertNotIn('expression(', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - SVG-based XSS
# -------------------------------------------------------------------------
def test_api_svg_onload_stripped(self):
"""SVG with onload handler is sanitized."""
response = self._create_note_with_content(
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'>"
"<rect width='100' height='100'/></svg>"
)
self.assertNotIn('onload', response.data['content'].lower())
def test_api_svg_animate_javascript_stripped(self):
"""SVG animate element with javascript: href value is stripped."""
response = self._create_note_with_content(
"<svg><animate attributeName='href' values='javascript:alert(1)'/></svg>"
)
self.assertNotIn('javascript:', response.data['content'].lower())
# -------------------------------------------------------------------------
# API - data URI injection
# -------------------------------------------------------------------------
def test_api_data_uri_in_img_src_stripped(self):
"""data: URI in img src containing a script payload is stripped."""
response = self._create_note_with_content(
'<img src="data:text/html,<script>alert(1)</script>">'
)
content = response.data['content']
self.assertNotIn('<script', content.lower())
# The data: URI should be stripped from the src attribute
self.assertNotIn('data:text/html', content.lower())
# -------------------------------------------------------------------------
# API - PATCH also sanitizes (not just POST)
# -------------------------------------------------------------------------
def test_api_patch_sanitizes_content(self):
"""Updating note content via PATCH also sanitises the payload."""
note = self._create_note_with_content('<p>Original safe content</p>')
pk = note.data['pk']
response = self.patch(
self._note_url(pk),
data={'content': "<script>alert('xss')</script><p>Updated</p>"},
expected_code=200,
)
content = response.data['content']
self.assertNotIn('<script', content.lower())
self.assertIn('Updated', content)
# -------------------------------------------------------------------------
# API - sanitized content is persisted, not just masked in response
# -------------------------------------------------------------------------
def test_sanitized_content_persisted_in_database(self):
"""Sanitization is applied before DB write, not only in the API response."""
from common.models import Note
payload = "<script>alert('xss')</script><p>Safe text</p>"
response = self._create_note_with_content(payload)
pk = response.data['pk']
note = Note.objects.get(pk=pk)
self.assertNotIn('<script', note.content.lower())
self.assertIn('Safe text', note.content)
# -------------------------------------------------------------------------
# Regression - safe HTML is not over-sanitized
# -------------------------------------------------------------------------
def test_safe_inline_formatting_preserved(self):
"""Legitimate inline HTML (strong, em) survives sanitization."""
safe_html = '<p>Normal <strong>bold</strong> and <em>italic</em> text</p>'
response = self._create_note_with_content(safe_html)
content = response.data['content']
self.assertIn('<strong>', content)
self.assertIn('<em>', content)
def test_safe_https_link_preserved(self):
"""An anchor with an https:// href is kept after sanitization."""
response = self._create_note_with_content(
'<a href="https://example.com">documentation</a>'
)
content = response.data['content']
self.assertIn('https://example.com', content)
self.assertIn('documentation', content)
def test_blockquote_preserved(self):
"""Block-level formatting elements such as blockquote are preserved."""
response = self._create_note_with_content(
'<blockquote><p>Quoted text</p></blockquote>'
)
content = response.data['content']
self.assertIn('<blockquote>', content)
self.assertIn('Quoted text', content)
def test_empty_content_accepted(self):
"""An empty content field is valid and stored as-is."""
response = self._create_note_with_content('')
self.assertEqual(response.data['content'], '')
def test_plain_text_content_preserved(self):
"""Plain text with no HTML tags is stored without modification."""
plain = 'Just plain text, no HTML here.'
response = self._create_note_with_content(plain)
self.assertEqual(response.data['content'], plain)
def test_html_entities_in_plain_text_not_executed(self):
"""HTML-entity-encoded script tags in plain text are not executed as markup."""
# &lt;script&gt; is already-escaped user text — it should be stored
# safely and not interpreted as a tag.
entity_payload = '&lt;script&gt;alert(1)&lt;/script&gt;'
response = self._create_note_with_content(entity_payload)
content = response.data['content']
# Must not contain a live <script> tag
self.assertNotIn('<script', content.lower())
class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
"""Tests for thumbnail generation when uploading attachments via the API."""
@ -1234,3 +1688,152 @@ class SelectionListLockedTest(InvenTreeAPITestCase):
# Entry must still exist — omitting choices must not delete entries
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
class NotePermissionAPITests(InvenTreeAPITestCase):
"""Tests for Note API permission enforcement.
Covers two requirements:
1. Users cannot create/edit notes against a model they lack change permission for.
2. Users cannot view notes against a model they lack view permission for.
"""
# No roles by default — each test assigns only what it needs
roles = []
def setUp(self):
"""Create a Part and a pre-existing note for permission tests."""
from django.contrib.contenttypes.models import ContentType
from common.models import Note
from part.models import Part
super().setUp()
self.part = Part.objects.create(
name='Perm Test Part', description='Part for permission testing'
)
# Create a note directly via ORM (bypasses API permission checks)
ct = ContentType.objects.get_for_model(Part)
self.note = Note.objects.create(
model_type=ct,
model_id=self.part.pk,
title='Pre-existing Note',
content='<p>content</p>',
)
def _note_url(self, pk=None):
if pk:
return reverse('api-note-detail', kwargs={'pk': pk})
return reverse('api-note-list')
# -------------------------------------------------------------------------
# Upload (create) permission checks
# -------------------------------------------------------------------------
def test_create_note_no_role_is_denied(self):
"""A user with no roles cannot create a note for a Part."""
self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': self.part.pk,
'title': 'Should Fail',
},
expected_code=403,
)
def test_create_note_view_only_role_is_denied(self):
"""A user with only part.view cannot create a note for a Part.
'view' does not imply 'change' in the InvenTree ruleset hierarchy.
"""
self.assignRole('part.view')
self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': self.part.pk,
'title': 'Should Fail',
},
expected_code=403,
)
def test_create_note_with_change_role_is_allowed(self):
"""A user with part.change can create a note for a Part."""
self.assignRole('part.change')
response = self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': self.part.pk,
'title': 'Should Succeed',
},
expected_code=201,
)
self.assertEqual(response.data['title'], 'Should Succeed')
def test_create_note_with_add_role_is_allowed(self):
"""A user with part.add can create a note (add implies change in InvenTree)."""
self.assignRole('part.add')
response = self.post(
self._note_url(),
data={
'model_type': 'part',
'model_id': self.part.pk,
'title': 'Add Role Note',
},
expected_code=201,
)
self.assertEqual(response.data['title'], 'Add Role Note')
# -------------------------------------------------------------------------
# View (read) permission checks
# -------------------------------------------------------------------------
def test_list_notes_no_role_returns_empty(self):
"""A user with no roles cannot see notes attached to a Part."""
response = self.get(
self._note_url(),
data={'model_type': 'part', 'model_id': self.part.pk},
expected_code=200,
)
self.assertEqual(len(response.data), 0)
def test_list_notes_with_view_role_returns_notes(self):
"""A user with part.view can see notes attached to a Part."""
self.assignRole('part.view')
response = self.get(
self._note_url(),
data={'model_type': 'part', 'model_id': self.part.pk},
expected_code=200,
)
pks = [n['pk'] for n in response.data]
self.assertIn(self.note.pk, pks)
def test_detail_note_no_role_returns_404(self):
"""A user with no roles gets 404 when accessing a note detail for a Part."""
self.get(self._note_url(self.note.pk), expected_code=404)
def test_detail_note_with_view_role_returns_200(self):
"""A user with part.view can access a specific note for a Part."""
self.assignRole('part.view')
response = self.get(self._note_url(self.note.pk), expected_code=200)
self.assertEqual(response.data['pk'], self.note.pk)
def test_list_notes_unrelated_role_does_not_leak(self):
"""A user with a role that has no Part access cannot see Part notes.
purchase_order.view covers company/order tables; 'part_part' is NOT
in that ruleset, so Part notes must remain invisible to this user.
Note: build.view would be wrong here because 'part_part' IS listed in
the build ruleset (builds need to read parts).
"""
self.assignRole('purchase_order.view')
response = self.get(
self._note_url(),
data={'model_type': 'part', 'model_id': self.part.pk},
expected_code=200,
)
self.assertEqual(len(response.data), 0)

View File

@ -290,6 +290,237 @@ class TestAttachmentThumbnailMigration(MigratorTestCase):
self.assertFalse(att.thumbnail)
class TestNoteMigrations(MigratorTestCase):
"""Test data migration of legacy 'notes' fields to the new Note model.
- Migration common.0047 copies existing 'notes' field data into the Note model,
converting the markdown content to HTML, and links any associated NotesImage objects.
- Migration common.0048 removes the legacy 'model_type' and 'model_id' fields from NotesImage.
"""
# Note: these targets must match the dependencies of the data migration (common.0047),
# to ensure that the migration plan is truncated *before* the data migration is applied
migrate_from = [
('common', '0046_note'),
('build', '0059_build_tags'),
('company', '0079_auto_20260212_1054'),
('order', '0119_transferorderlineitem_line_int'),
('part', '0150_part_maximum_stock'),
('stock', '0123_remove_stockitem_review_needed'),
]
migrate_to = ('common', '0049_remove_notesimage_model_id_and_more')
def generate_image(self, name: str):
"""Generate a dummy image file for upload."""
buf = io.BytesIO()
Image.new('RGB', (64, 64), color='red').save(buf, format='PNG')
return ContentFile(buf.getvalue(), name=name)
def get_model(self, app: str, model: str):
"""Fetch a historical model, working around the duplicate-column ORM bug.
Historical models with a custom status field enumerate 'status_custom_key' twice
(once from contribute_to_class on the status field, once from the explicit
AddField migration), so ORM-generated INSERTs fail with
'column specified more than once'. Remove the duplicated field entries here.
"""
model_class = self.old_state.apps.get_model(app, model)
seen = set()
for field in list(model_class._meta.local_fields):
if field.name in seen:
model_class._meta.local_fields.remove(field)
else:
seen.add(field.name)
model_class._meta._expire_cache()
return model_class
def prepare(self):
"""Create instances of each model type which supports notes."""
# Dummy MPPT data
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
NotesImage = self.get_model('common', 'NotesImage')
Part = self.get_model('part', 'Part')
Build = self.get_model('build', 'Build')
StockItem = self.get_model('stock', 'StockItem')
Company = self.get_model('company', 'Company')
ManufacturerPart = self.get_model('company', 'ManufacturerPart')
SupplierPart = self.get_model('company', 'SupplierPart')
PurchaseOrder = self.get_model('order', 'PurchaseOrder')
SalesOrder = self.get_model('order', 'SalesOrder')
ReturnOrder = self.get_model('order', 'ReturnOrder')
SalesOrderShipment = self.get_model('order', 'SalesOrderShipment')
TransferOrder = self.get_model('order', 'TransferOrder')
# An image which is not linked to any model, but is embedded in the notes markdown
embedded_image = NotesImage.objects.create(
image=self.generate_image('embedded.png')
)
# An image which is not linked to any model, and not referenced anywhere
NotesImage.objects.create(image=self.generate_image('orphan.png'))
part = Part.objects.create(
name='Test Part',
description='Test Part Description',
active=True,
assembly=True,
purchaseable=True,
notes=f'Some **bold** part notes\n\n![image]({embedded_image.image.url})',
**tree,
)
# Parts with empty notes should not generate a Note entry
Part.objects.create(
name='Part with empty notes', description='x', notes='', **tree
)
Part.objects.create(
name='Part with null notes', description='x', notes=None, **tree
)
# An image which is directly linked to the part instance
NotesImage.objects.create(
image=self.generate_image('linked.png'), model_type='part', model_id=part.pk
)
company = Company.objects.create(
name='Test Company',
description='Test Company Description',
is_customer=True,
is_manufacturer=True,
is_supplier=True,
notes='Some **bold** company notes',
)
so = SalesOrder.objects.create(
reference='SO-12345',
customer=company,
description='Test Sales Order Description',
notes='Some **bold** sales order notes',
)
instances = [
('part', part),
('company', company),
('salesorder', so),
(
'manufacturerpart',
ManufacturerPart.objects.create(
part=part,
manufacturer=company,
MPN='MPN-123',
notes='Some **bold** manufacturer part notes',
),
),
(
'supplierpart',
SupplierPart.objects.create(
part=part,
supplier=company,
SKU='SKU-123',
notes='Some **bold** supplier part notes',
),
),
(
'build',
Build.objects.create(
part=part,
reference='BO-0001',
title='Test Build',
quantity=10,
notes='Some **bold** build notes',
**tree,
),
),
(
'stockitem',
StockItem.objects.create(
part=part,
quantity=10,
notes='Some **bold** stock item notes',
**tree,
),
),
(
'purchaseorder',
PurchaseOrder.objects.create(
reference='PO-12345',
supplier=company,
description='Test Purchase Order Description',
notes='Some **bold** purchase order notes',
),
),
(
'returnorder',
ReturnOrder.objects.create(
reference='RO-12345',
customer=company,
description='Test Return Order Description',
notes='Some **bold** return order notes',
),
),
(
'salesordershipment',
SalesOrderShipment.objects.create(
order=so, reference='SHIP-001', notes='Some **bold** shipment notes'
),
),
(
'transferorder',
TransferOrder.objects.create(
reference='TO-12345',
description='Test Transfer Order Description',
notes='Some **bold** transfer order notes',
),
),
]
# Record the expected (model_type, model_id) values for later comparison
self.expected_notes = [(model, instance.pk) for model, instance in instances]
self.part_pk = part.pk
def test_notes_migrated(self):
"""Test that a Note object has been created for each legacy notes field."""
Note = self.new_state.apps.get_model('common', 'Note')
ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType')
# One note per instance with non-empty notes (and no extras)
self.assertEqual(Note.objects.count(), len(self.expected_notes))
for model, pk in self.expected_notes:
content_type = ContentType.objects.get(model=model)
note = Note.objects.get(model_type=content_type, model_id=pk)
self.assertEqual(note.title, 'Note')
self.assertTrue(note.primary)
self.assertFalse(note.template)
# Markdown content has been converted to HTML
self.assertIn('<strong>bold</strong>', note.content)
self.assertNotIn('**', note.content)
def test_images_migrated(self):
"""Test that NotesImage objects are correctly linked or removed."""
Note = self.new_state.apps.get_model('common', 'Note')
ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType')
NotesImage = self.new_state.apps.get_model('common', 'NotesImage')
# The orphaned image has been removed
self.assertEqual(NotesImage.objects.count(), 2)
content_type = ContentType.objects.get(model='part')
note = Note.objects.get(model_type=content_type, model_id=self.part_pk)
# Both the directly linked image and the embedded image point to the part note
for image in NotesImage.objects.all():
self.assertEqual(image.note.pk, note.pk)
def prep_currency_migration(self, vals: str):
"""Prepare the environment for the currency migration tests."""
# Set keys

View File

@ -42,6 +42,7 @@ from .models import (
InvenTreeCustomUserStateModel,
InvenTreeSetting,
InvenTreeUserSetting,
Note,
NotesImage,
NotificationEntry,
NotificationMessage,
@ -1693,6 +1694,128 @@ class NotesImageTest(InvenTreeAPITestCase):
# Check that a new file has been created
self.assertEqual(NotesImage.objects.count(), n + 1)
def test_image_cleanup(self):
"""Images no longer referenced in note content are deleted when the note is saved.
Specifically:
- An image removed from the content is deleted (DB record and file on disk)
- An image still referenced in the content is preserved (DB record and file on disk)
"""
part = Part.objects.create(
name='Note Cleanup Test Part', description='Part for image-cleanup test'
)
part_ct = ContentType.objects.get_for_model(Part)
note = Note(
model_type=part_ct,
model_id=part.pk,
title='Image Cleanup Test Note',
content='initial',
)
note.save()
# Build a minimal valid PNG in memory
img_obj = Image.new('RGB', (10, 10), color='blue')
with io.BytesIO() as buf:
img_obj.save(buf, format='PNG')
png_bytes = buf.getvalue()
# Attach two images to the note
ni1 = NotesImage(note=note)
ni1.image.save('cleanup_keep.png', ContentFile(png_bytes))
ni2 = NotesImage(note=note)
ni2.image.save('cleanup_remove.png', ContentFile(png_bytes))
url1, url2 = ni1.image.url, ni2.image.url
name1, name2 = ni1.image.name, ni2.image.name
# Both records and files exist before any content-driven cleanup
self.assertEqual(note.images.count(), 2)
self.assertTrue(default_storage.exists(name1))
self.assertTrue(default_storage.exists(name2))
# Save with content that references both images — nothing should be removed
note.content = f'<img src="{url1}"><img src="{url2}">'
note.save()
self.assertEqual(note.images.count(), 2)
self.assertTrue(default_storage.exists(name1))
self.assertTrue(default_storage.exists(name2))
# Remove the second image from the content and save
note.content = f'<img src="{url1}">'
note.save()
# The removed image must be gone from both the DB and the file system
self.assertFalse(NotesImage.objects.filter(pk=ni2.pk).exists())
self.assertFalse(default_storage.exists(name2))
# The retained image must still exist in both the DB and the file system
self.assertTrue(NotesImage.objects.filter(pk=ni1.pk).exists())
self.assertTrue(default_storage.exists(name1))
def test_copy_notes_with_images(self):
"""Images are duplicated (file + DB record) when copy_notes_from is called.
Specifically:
- New NotesImage records are created pointing to the new notes
- The image files are physically copied (independent from the source)
- The new note content references the new image URLs, not the old ones
- Deleting the source note does not affect the copied note's images
"""
# Build a minimal valid PNG in memory
img_obj = Image.new('RGB', (10, 10), color='green')
with io.BytesIO() as buf:
img_obj.save(buf, format='PNG')
png_bytes = buf.getvalue()
part_ct = ContentType.objects.get_for_model(Part)
src_part = Part.objects.create(
name='Copy Notes Source Part',
description='Source part for copy_notes_from test',
)
dst_part = Part.objects.create(
name='Copy Notes Dest Part',
description='Destination part for copy_notes_from test',
)
src_note = Note(
model_type=part_ct, model_id=src_part.pk, title='Src Note', content=''
)
src_note.save()
ni = NotesImage(note=src_note)
ni.image.save('copy_test.png', ContentFile(png_bytes))
old_url = ni.image.url
old_name = ni.image.name
src_note.content = f'![img]({old_url})'
src_note.save()
dst_part.copy_notes_from(src_part)
dst_note = dst_part.notes_list.get(title='Src Note')
# A new NotesImage must exist for the destination note
self.assertEqual(dst_note.images.count(), 1)
new_img = dst_note.images.first()
# The file must be a distinct copy
self.assertNotEqual(new_img.image.name, old_name)
self.assertTrue(default_storage.exists(new_img.image.name))
# The new note content must reference the new URL, not the old one
self.assertIn(new_img.image.url, dst_note.content)
self.assertNotIn(old_url, dst_note.content)
# Deleting the source NotesImage must not remove the copied image
# (files are independent; Django cascade does not call Python delete())
ni.delete()
self.assertFalse(default_storage.exists(old_name))
self.assertTrue(default_storage.exists(new_img.image.name))
self.assertTrue(NotesImage.objects.filter(pk=new_img.pk).exists())
class ProjectCodesTest(InvenTreeAPITestCase):
"""Units tests for the ProjectCodes model and API endpoints."""

View File

@ -11,8 +11,27 @@ import common.icons
from common.settings import get_global_setting
def note_model_types():
"""Return a list of valid note model choices."""
import InvenTree.helpers_model
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNoteMixin)
)
def note_model_options():
"""Return a list of options for models which support notes."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in note_model_types()
]
def parameter_model_types():
"""Return a list of valid parameter model choices."""
import InvenTree.helpers_model
import InvenTree.models
return list(
@ -42,6 +61,7 @@ def parameter_template_model_options():
def attachment_model_types():
"""Return a list of valid attachment model choices."""
import InvenTree.helpers_model
import InvenTree.models
return list(
@ -93,28 +113,6 @@ def validate_attachment_file(attachment):
raise ValidationError(_('Invalid file name'))
def validate_notes_model_type(value):
"""Ensure that the provided model type is valid.
The provided value must map to a model which implements the 'InvenTreeNotesMixin'.
"""
import InvenTree.helpers_model
import InvenTree.models
if not value:
# Empty values are allowed
return
model_types = list(
InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin)
)
model_names = [model.__name__.lower() for model in model_types]
if value.lower() not in model_names:
raise ValidationError(f"Invalid model type '{value}'")
def validate_decimal_places_min(value):
"""Validator for PRICING_DECIMAL_PLACES_MIN setting."""
try:

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.14 on 2026-05-25 12:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("company", "0080_company_tags"),
("common", "0049_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="company",
name="notes",
),
migrations.RemoveField(
model_name="manufacturerpart",
name="notes",
),
migrations.RemoveField(
model_name="supplierpart",
name="notes",
),
]

View File

@ -78,7 +78,7 @@ class CompanyReportContext(report.mixins.BaseReportContext, TypedDict):
class Company(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
@ -486,7 +486,7 @@ class ManufacturerPart(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeMetadataModel,
):
@ -602,8 +602,8 @@ class SupplierPart(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):

View File

@ -23,7 +23,6 @@ from InvenTree.serializers import (
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
@ -111,7 +110,6 @@ class CompanySerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for Company object (full detail)."""
@ -143,7 +141,6 @@ class CompanySerializer(
'is_customer',
'is_manufacturer',
'is_supplier',
'notes',
'parts_supplied',
'parts_manufactured',
'primary_address',
@ -196,7 +193,9 @@ class CompanySerializer(
parameters = common.filters.enable_parameters_filter()
duplicate = DuplicateOptionsSerializer(Company.objects.all(), copy_parameters=True)
duplicate = DuplicateOptionsSerializer(
Company.objects.all(), copy_parameters=True, copy_notes=True
)
@transaction.atomic
def create(self, validated_data):
@ -211,6 +210,9 @@ class CompanySerializer(
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
if duplicate.get('copy_notes', True):
instance.copy_notes_from(original)
return instance
@ -234,7 +236,6 @@ class ManufacturerPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for ManufacturerPart object."""
@ -257,7 +258,6 @@ class ManufacturerPartSerializer(
'MPN',
'link',
'barcode_hash',
'notes',
'tags',
'parameters',
]
@ -267,7 +267,7 @@ class ManufacturerPartSerializer(
parameters = common.filters.enable_parameters_filter()
duplicate = DuplicateOptionsSerializer(
ManufacturerPart.objects.all(), copy_parameters=True
ManufacturerPart.objects.all(), copy_parameters=True, copy_notes=True
)
@transaction.atomic
@ -283,6 +283,9 @@ class ManufacturerPartSerializer(
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
if duplicate.get('copy_notes', True):
instance.copy_notes_from(original)
return instance
part_detail = OptionalField(
@ -358,7 +361,6 @@ class SupplierPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPart object."""
@ -407,7 +409,6 @@ class SupplierPartSerializer(
'supplier',
'supplier_detail',
'updated',
'notes',
'part_detail',
'tags',
'price_breaks',
@ -542,7 +543,7 @@ class SupplierPartSerializer(
updated = serializers.DateTimeField(allow_null=True, read_only=True)
duplicate = DuplicateOptionsSerializer(
SupplierPart.objects.all(), copy_parameters=True
SupplierPart.objects.all(), copy_parameters=True, copy_notes=True
)
@staticmethod
@ -601,6 +602,9 @@ class SupplierPartSerializer(
if duplicate.get('copy_parameters', True):
supplier_part.copy_parameters_from(original)
if duplicate.get('copy_notes', True):
supplier_part.copy_notes_from(original)
return supplier_part

View File

@ -40,7 +40,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
session.extract_columns()
self.assertEqual(session.column_mappings.count(), 14)
self.assertEqual(session.column_mappings.count(), 13)
# Check some of the field mappings
for field, col in [

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.14 on 2026-05-25 12:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("order", "0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more"),
("common", "0049_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="purchaseorder",
name="notes",
),
migrations.RemoveField(
model_name="returnorder",
name="notes",
),
migrations.RemoveField(
model_name="salesorder",
name="notes",
),
migrations.RemoveField(
model_name="salesordershipment",
name="notes",
),
migrations.RemoveField(
model_name="transferorder",
name="notes",
),
]

View File

@ -299,7 +299,7 @@ class Order(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
@ -1048,6 +1048,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
# List of line items to update
line_items_to_update: list[PurchaseOrderLineItem] = []
# Map of tree_id -> note text, for items awaiting a Note once they have a pk
pending_notes: dict[int, str] = {}
convert_purchase_price = get_global_setting('PURCHASEORDER_CONVERT_CURRENCY')
default_currency = currency_code_default()
@ -1139,6 +1142,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
else:
purchase_price = None
# Note text supplied for this line - attached as a Note once the item has a pk
line_note = item.get('note', '') or item.get('notes', '')
# Construct dataset for creating a new StockItem instances
stock_data = {
'part': supplier_part.part,
@ -1149,7 +1155,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
'quantity': 1 if serialize else stock_quantity,
'batch': item.get('batch_code', ''),
'expiry_date': item.get('expiry_date', None),
'notes': item.get('note', '') or item.get('notes', ''),
'packaging': item.get('packaging') or supplier_part.packaging,
}
@ -1206,6 +1211,8 @@ class PurchaseOrder(TotalPriceMixin, Order):
item.validate_batch_code()
# run validation for serialized items plugin.validate_model_instance
item.run_plugin_validation()
if line_note:
pending_notes[item.tree_id] = line_note
stock_items.append(item)
else:
@ -1219,6 +1226,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
rght=2,
)
if line_note:
pending_notes[new_item.tree_id] = line_note
new_item.set_status(status, custom_values=custom_stock_status_values)
if barcode:
@ -1248,6 +1258,18 @@ class PurchaseOrder(TotalPriceMixin, Order):
stock_items.extend(created_items)
# Attach any per-line notes to their corresponding stock item
if pending_notes:
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(stock.models.StockItem)
for item in stock_items:
if note := pending_notes.get(item.tree_id):
common_models.Note.objects.create(
model_type=content_type, model_id=item.pk, content=note
)
# Generate a new tracking entry for each stock item
for item in stock_items:
tracking_entries.append(
@ -2484,8 +2506,8 @@ class SalesOrderShipment(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeModel,
@ -2502,7 +2524,6 @@ class SalesOrderShipment(
shipment_date: Date this shipment was "shipped" (or null)
checked_by: User reference field indicating who checked this order
reference: Custom reference text for this shipment (e.g. consignment number?)
notes: Custom notes field for this shipment
"""
@classmethod

View File

@ -39,7 +39,6 @@ from InvenTree.serializers import (
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
)
from order.status_codes import (
@ -78,7 +77,6 @@ class AbstractOrderSerializer(
"""Abstract serializer class which provides fields common to all order types."""
export_exclude_fields = ['notes']
import_exclude_fields = ['notes']
# Number of line items in this order
@ -225,7 +223,6 @@ class AbstractOrderSerializer(
'status',
'status_text',
'status_custom_key',
'notes',
'barcode_hash',
'overdue',
'duplicate',
@ -272,6 +269,9 @@ class AbstractOrderSerializer(
if duplicate.get('copy_parameters', False):
instance.copy_parameters_from(original)
if duplicate.get('copy_notes', False):
instance.copy_notes_from(original)
return instance
@ -366,7 +366,6 @@ class AbstractExtraLineMeta:
@register_importer()
class PurchaseOrderSerializer(
NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
@ -411,6 +410,7 @@ class PurchaseOrderSerializer(
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@ -1060,7 +1060,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
@register_importer()
class SalesOrderSerializer(
NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
@ -1100,6 +1099,7 @@ class SalesOrderSerializer(
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@ -1374,7 +1374,6 @@ class SalesOrderShipmentSerializer(
DataImportExportSerializerMixin,
FilterableSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrderShipment class."""
@ -1399,7 +1398,6 @@ class SalesOrderShipmentSerializer(
'invoice_number',
'barcode_hash',
'link',
'notes',
# Extra detail fields
'parameters',
'checked_by_detail',
@ -1479,7 +1477,9 @@ class SalesOrderShipmentSerializer(
tags = common.filters.enable_tags_filter()
duplicate = DuplicateOptionsSerializer(
order.models.SalesOrderShipment.objects.all(), copy_parameters=True
order.models.SalesOrderShipment.objects.all(),
copy_parameters=True,
copy_notes=True,
)
@transaction.atomic
@ -1495,6 +1495,9 @@ class SalesOrderShipmentSerializer(
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
if duplicate.get('copy_notes', True):
instance.copy_notes_from(original)
return instance
@ -2159,7 +2162,6 @@ class SalesOrderExtraLineSerializer(
@register_importer()
class ReturnOrderSerializer(
NotesFieldMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
TotalPriceMixin,
@ -2194,6 +2196,7 @@ class ReturnOrderSerializer(
order.models.ReturnOrder.objects.all(),
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@ -2455,7 +2458,6 @@ class ReturnOrderExtraLineSerializer(
@register_importer()
class TransferOrderSerializer(
NotesFieldMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeModelSerializer,
@ -2485,7 +2487,10 @@ class TransferOrderSerializer(
# Note: TransferOrder does not have "extra" line items
duplicate = DuplicateOptionsSerializer(
order.models.TransferOrder.objects.all(), copy_lines=True, copy_parameters=True
order.models.TransferOrder.objects.all(),
copy_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod

View File

@ -104,7 +104,6 @@
fields:
name: 'Bob'
description: 'Can we build it? Yes we can!'
notes: 'Some notes associated with this part'
assembly: true
salable: true
purchaseable: false

View File

@ -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 = [
("part", "0150_part_maximum_stock"),
("common", "0049_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="part",
name="notes",
),
]

View File

@ -465,8 +465,8 @@ class Part(
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin,
InvenTree.models.MetadataMixin,

View File

@ -531,7 +531,6 @@ class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class PartSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
):
@ -573,7 +572,6 @@ class PartSerializer(
'minimum_stock',
'maximum_stock',
'name',
'notes',
'parameters',
'pk',
'purchaseable',
@ -1048,8 +1046,7 @@ class PartSerializer(
instance.copy_bom_from(original)
if duplicate.get('copy_notes', False):
instance.notes = original.notes
instance.save()
instance.copy_notes_from(original)
if duplicate.get('copy_image', False):
instance.image = original.image

View File

@ -5,6 +5,7 @@ from datetime import datetime
from decimal import Decimal
from random import randint
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
@ -18,7 +19,7 @@ import build.models
import company.models
import order.models
from build.status_codes import BuildStatus
from common.models import InvenTreeSetting, ParameterTemplate
from common.models import InvenTreeSetting, Note, ParameterTemplate
from company.models import Company, SupplierPart
from InvenTree.config import get_testfolder_dir
from InvenTree.unit_test import InvenTreeAPIPerformanceTestCase, InvenTreeAPITestCase
@ -1639,6 +1640,14 @@ class PartCreationTests(PartAPITestBase):
description=f'Test template {key} for duplication',
)
# Attach a note to the base part
Note.objects.create(
model_type=ContentType.objects.get_for_model(Part),
model_id=base_part.pk,
title='Duplication test note',
content='Some note content',
)
for do_copy in [True, False]:
response = self.post(
reverse('api-part-list'),
@ -1664,7 +1673,7 @@ class PartCreationTests(PartAPITestBase):
# Check new part
self.assertEqual(part.bom_items.count(), 4 if do_copy else 0)
self.assertEqual(part.notes, base_part.notes if do_copy else None)
self.assertEqual(part.notes.count(), 1 if do_copy else 0)
self.assertEqual(part.parameters.count(), 2 if do_copy else 0)
self.assertEqual(part.test_templates.count(), 3 if do_copy else 0)
@ -2186,20 +2195,33 @@ class PartNotesTests(InvenTreeAPITestCase):
roles = ['part.change', 'part.add']
def test_long_notes(self):
"""Test that very long notes field is rejected."""
# Ensure that we cannot upload a very long piece of text
url = reverse('api-part-detail', kwargs={'pk': 1})
"""Test that a very long note content field is rejected.
response = self.patch(url, {'notes': 'abcde' * 10001}, expected_code=400)
Notes are no longer stored directly on the Part model - they are stored
as generic 'Note' instances, linked via a generic foreign key.
"""
# Ensure that we cannot upload a very long piece of text
url = reverse('api-note-list')
response = self.post(
url,
{
'model_type': 'part',
'model_id': 1,
'title': 'Test Note',
'content': 'abcde' * 10001,
},
expected_code=400,
)
self.assertIn(
'Ensure this field has no more than 50000 characters',
str(response.data['notes']),
str(response.data['content']),
)
def test_multiline_formatting(self):
"""Ensure that markdown formatting is retained."""
url = reverse('api-part-detail', kwargs={'pk': 1})
"""Ensure that markdown formatting is retained in a note's content."""
url = reverse('api-note-list')
notes = """
### Title
@ -2212,13 +2234,22 @@ class PartNotesTests(InvenTreeAPITestCase):
"""
response = self.patch(url, {'notes': notes}, expected_code=200)
response = self.post(
url,
{
'model_type': 'part',
'model_id': 1,
'title': 'Test Note',
'content': notes,
},
expected_code=201,
)
# Ensure that newline chars have not been removed
self.assertIn('\n', response.data['notes'])
self.assertIn('\n', response.data['content'])
# Entire notes field should match original value
self.assertEqual(response.data['notes'], notes.strip())
# Entire note content should match original value
self.assertEqual(response.data['content'], notes.strip())
class PartPricingDetailTests(InvenTreeAPITestCase):

View File

@ -22,6 +22,7 @@ from django.utils import translation
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import gettext_lazy as _
import lxml.html
from babel import Locale
from babel.core import UnknownLocaleError
from babel.dates import format_date as babel_format_date
@ -499,6 +500,94 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa
)
@register.simple_tag()
def note_instance(
instance: Model, title: Optional[str] = None
) -> Optional[common.models.Note]:
"""Return a Note object for the given instance and note name.
Arguments:
instance: A Model object
title: The title of the note to retrieve (case insensitive)
Returns:
A Note object, or None if not found
Note: If the 'title' argument is not provided, the first Note object associated with the instance will be returned (if any).
"""
if not instance:
raise ValueError('notes tag requires a valid Model instance')
if not hasattr(instance, 'notes'):
raise TypeError("notes tag requires a Model with a 'notes' attribute")
notes = instance.notes
if title:
# First try with exact match
if note := notes.filter(title=title).first():
return note
# Next, try with case-insensitive match
if note := notes.filter(title__iexact=title).first():
return note
# If no title is provided, or if no matching note is found, return the first note (if any)
return notes.order_by('-primary').first()
@register.simple_tag()
def note(instance: Model, title: Optional[str] = None) -> str:
"""Return the HTML content of a Note object for the given instance and note name.
Arguments:
instance: A Model object
title: The title of the note to retrieve (case insensitive)
Returns:
The HTML content of the Note, or an empty string if not found
Note: If the 'title' argument is not provided, the first Note object associated with the instance will be returned (if any).
"""
note = note_instance(instance, title)
if not note or not note.content:
return ''
content = note.content
media_prefix = settings.MEDIA_URL
# Replace any embedded image references with the actual image data
root = lxml.html.fragment_fromstring(content, create_parent='div')
for img in root.iter('img'):
src = img.get('src')
if not src:
continue
if not src.startswith(media_prefix):
continue
img_src = src[len(media_prefix) :]
# Extract img size attributes
img_data = uploaded_image(
img_src,
replace_missing=True,
width=img.get('width', None),
height=img.get('height', None),
)
# Replace the <img> src attribute
img.set('src', img_data)
content = lxml.html.tostring(root, encoding='unicode')
# fragment_fromstring wraps in a <div> — strip it back off
content = content.removeprefix('<div>').removesuffix('</div>')
return mark_safe(content)
@register.simple_tag()
def parameter(
instance: Model, parameter_name: str

View File

@ -1250,9 +1250,28 @@ class StockList(
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
# Extract 'duplicate' options (if provided) - these are not valid model fields
duplicate = serializer.validated_data.pop('duplicate', None)
# Extract location information
location = serializer.validated_data.get('location', None)
def apply_duplicate_options(item):
"""Apply any provided 'duplicate' options to a newly created StockItem."""
if not duplicate:
return
original = duplicate['original']
if duplicate.get('copy_notes', True):
item.copy_notes_from(original)
if duplicate.get('copy_history', False):
item.copyHistoryFrom(original)
if duplicate.get('copy_tests', False):
item.copyTestResultsFrom(original)
with transaction.atomic():
if serials:
# Create multiple serialized StockItem objects
@ -1268,6 +1287,8 @@ class StockList(
item.set_status(status_value)
item.save()
apply_duplicate_options(item)
if entry := item.add_tracking_entry(
StockHistoryCode.CREATED,
user,
@ -1300,6 +1321,8 @@ class StockList(
item.save(user=user)
item.refresh_from_db()
apply_duplicate_options(item)
response_data = [
StockSerializers.StockItemSerializer(
item, context=self.get_serializer_context()

View File

@ -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 = [
("stock", "0123_remove_stockitem_review_needed"),
("common", "0049_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="stockitem",
name="notes",
),
]

View File

@ -423,7 +423,7 @@ class StockItem(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
StatusCodeMixin,
report.mixins.InvenTreeReportMixin,

View File

@ -324,6 +324,8 @@ class StockItemSerializer(
export_exclude_fields = ['tags', 'tracking_items']
SKIP_CREATE_FIELDS = ['duplicate']
export_child_fields = [
'part_detail.name',
'part_detail.description',
@ -359,7 +361,6 @@ class StockItemSerializer(
'in_stock',
'is_building',
'link',
'notes',
'owner',
'packaging',
'parent',
@ -381,6 +382,7 @@ class StockItemSerializer(
'purchase_price_currency',
'use_pack_size',
'serial_numbers',
'duplicate',
# Annotated fields
'allocated',
'expired',
@ -468,6 +470,28 @@ class StockItemSerializer(
help_text=_('Enter serial numbers for new items'),
)
# Extra field used only for creation of a new StockItem instance
duplicate = InvenTree.serializers.DuplicateOptionsSerializer(
StockItem.objects.all(),
label=_('Duplicate Stock Item'),
help_text=_('Copy initial data from another stock item'),
copy_notes=True,
copy_fields=[
{
'name': 'copy_tests',
'label': _('Copy Test Results'),
'help_text': _('Copy test results from the original stock item'),
'default': False,
},
{
'name': 'copy_history',
'label': _('Copy History'),
'help_text': _('Copy stock history from the original stock item'),
'default': False,
},
],
)
def validate_part(self, part):
"""Ensure the provided Part instance is valid."""
if part.virtual:

View File

@ -192,6 +192,7 @@ def get_ruleset_ignore() -> list[str]:
'common_inventreeusersetting',
'common_notificationentry',
'common_notificationmessage',
'common_note',
'common_notesimage',
'common_projectcode',
'common_webhookendpoint',

View File

@ -253,7 +253,8 @@ export enum ApiEndpoints {
error_report_list = 'error-report/',
project_code_list = 'project-code/',
custom_unit_list = 'units/',
notes_image_upload = 'notes-image-upload/',
note_list = 'note/',
notes_image_list = 'note/image/',
email_list = 'admin/email/',
email_test = 'admin/email/test/',
config_list = 'admin/config/',

View File

@ -325,5 +325,12 @@ export const ModelInformationDict: ModelDict = {
label_multiple: () => t`Tags`,
api_endpoint: ApiEndpoints.tag_list,
icon: 'tag'
},
notetemplate: {
label: () => t`Note Template`,
label_multiple: () => t`Note Templates`,
url_overview: '/settings/admin/notes',
api_endpoint: ApiEndpoints.note_list,
icon: 'notes'
}
};

View File

@ -39,7 +39,8 @@ export enum ModelType {
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error',
tag = 'tag'
tag = 'tag',
notetemplate = 'notetemplate'
}
export enum PluginPanelKey {

View File

@ -49,6 +49,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@floating-ui/dom": "^1.0.0",
"@emotion/react": "^11.14.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
@ -71,11 +72,20 @@
"@mantine/modals": "^9.2.1",
"@mantine/notifications": "^9.2.1",
"@mantine/spotlight": "^9.2.1",
"@mantine/tiptap": "^9.2.1",
"@mantine/utils": "^6.0.22",
"@mantine/vanilla-extract": "^9.2.1",
"@messageformat/date-skeleton": "^1.1.0",
"@sentry/react": "^10.57.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.101.0",
"@tiptap/core": "^3.23.6",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/extension-link": "^3.23.6",
"@tiptap/extension-table": "^3.23.6",
"@tiptap/pm": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@uiw/codemirror-theme-vscode": "^4.25.8",
"@uiw/react-codemirror": "^4.25.8",
"@uiw/react-split": "^5.9.4",
@ -85,7 +95,6 @@
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"easymde": "^2.21.0",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"fuse.js": "^7.4.2",
@ -100,10 +109,10 @@
"react-is": "^19.2.7",
"react-router-dom": "^6.30.4",
"react-select": "^5.10.2",
"react-simplemde-editor": "^5.2.0",
"react-window": "1.8.11",
"recharts": "^3.8.1",
"styled-components": "^6.4.2",
"tiptap-extension-resizable-image": "^2.1.0",
"undici": "^8.4.1",
"zustand": "^5.0.14"
},
@ -140,7 +149,10 @@
"vite-plugin-externals": "^0.6.2",
"vite-plugin-istanbul": "^9.0.1"
},
"overrides": {
},
"resolutions": {
"glob": "^13.0.0",
"undici": "^6.24.0",
"vite": "^7",
"js-yaml": "^4",

View File

@ -0,0 +1,82 @@
/* Table styles for the Tiptap notes editor */
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
overflow: hidden;
margin: 0;
}
.ProseMirror table td,
.ProseMirror table th {
border: 1px solid var(--mantine-color-gray-4);
padding: 4px 8px;
vertical-align: top;
box-sizing: border-box;
position: relative;
min-width: 50px;
}
[data-mantine-color-scheme='dark'] .ProseMirror table td,
[data-mantine-color-scheme='dark'] .ProseMirror table th {
border-color: var(--mantine-color-dark-4);
}
.ProseMirror table th {
background-color: var(--mantine-color-gray-1);
font-weight: bold;
}
[data-mantine-color-scheme='dark'] .ProseMirror table th {
background-color: var(--mantine-color-dark-6);
}
/* Selected cell highlight */
.ProseMirror table .selectedCell::after {
content: '';
position: absolute;
inset: 0;
background: var(--mantine-color-blue-1);
opacity: 0.4;
pointer-events: none;
}
[data-mantine-color-scheme='dark'] .ProseMirror table .selectedCell::after {
background: var(--mantine-color-blue-9);
}
/* Column resize handle */
.ProseMirror table .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
background-color: var(--mantine-color-blue-5);
pointer-events: none;
cursor: col-resize;
}
/* Scrollable wrapper for wide tables */
.ProseMirror .tableWrapper {
overflow-x: auto;
}
/* Resize cursor while dragging */
.ProseMirror.resize-cursor {
cursor: col-resize;
}
/* Disable image interaction when not in editing mode */
.mantine-RichTextEditor-root:not([data-editing]) .ProseMirror img {
pointer-events: none;
user-select: none;
}
.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-component {
outline: none;
}
.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-resizer {
display: none;
}

View File

@ -1,239 +1,703 @@
import { t } from '@lingui/core/macro';
import { RichTextEditor } from '@mantine/tiptap';
import '@mantine/tiptap/styles.css';
import { useHotkeys } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { useQuery } from '@tanstack/react-query';
import { TableKit } from '@tiptap/extension-table';
import { useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import DOMPurify from 'dompurify';
import EasyMDE, { type default as SimpleMde } from 'easymde';
import 'easymde/dist/easymde.min.css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import SimpleMDE from 'react-simplemde-editor';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ResizableImage } from 'tiptap-extension-resizable-image';
import 'tiptap-extension-resizable-image/styles.css';
import './NotesEditor.css';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { useApi } from '../../contexts/ApiContext';
/*
* A text editor component for editing notes against a model type and instance.
* Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor
*
* TODO:
* - Disable editing by default when the component is launched - user can click an "edit" button to enable
* - Allow image resizing in the future (requires back-end validation changes))
* - Allow user to configure the editor toolbar (i.e. hide some buttons if they don't want them)
*/
import { identifierString } from '@lib/functions/Conversion';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
FileButton,
Flex,
Group,
HoverCard,
Paper,
Stack,
Tabs,
Text,
Tooltip
} from '@mantine/core';
import {
IconCheck,
IconCirclePlus,
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconDeviceFloppy,
IconInfoCircle,
IconPencil,
IconPhoto,
IconReload,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconStar,
IconTableOff,
IconTablePlus,
IconTableRow
} from '@tabler/icons-react';
import { useShallow } from 'zustand/react/shallow';
import { formatDate } from '../../defaults/formatters';
import { useNoteFields, useNoteTemplateFields } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useLocalState } from '../../states/LocalState';
import { useUserState } from '../../states/UserState';
import {
DeleteItemAction,
EditItemAction,
OptionsActionDropdown
} from '../items/ActionDropdown';
import { RenderUser } from '../render/User';
function NoteInfoHover({ note }: { note: any }) {
if (!note?.pk) {
return null;
}
return (
<HoverCard position='top-start'>
<HoverCard.Target>
<ActionIcon variant='transparent'>
<IconInfoCircle />
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Paper p='sm' shadow='sm' withBorder>
<Stack gap='xs'>
{note.updated && (
<Group gap='xs' justify='space-between'>
<Text fw='bold'>{t`Updated`}</Text>
<Text size='xs'>
{formatDate(note.updated, { showTime: true })}
</Text>
</Group>
)}
{note.updated_by_detail && (
<Group gap='xs' justify='space-between'>
<Text fw='bold'>{t`Updated by`}</Text>
<RenderUser instance={note.updated_by_detail} />
</Group>
)}
</Stack>
</Paper>
</HoverCard.Dropdown>
</HoverCard>
);
}
export default function NotesEditor({
modelType,
modelId,
editable,
templateMode = false,
setDirtyCallback
}: Readonly<{
modelType: ModelType;
modelId: number;
editable?: boolean;
modelType?: ModelType;
modelId?: number;
templateMode?: boolean;
setDirtyCallback?: (dirty: boolean) => void;
}>) {
const api = useApi();
// In addition to the editable prop, we also need to check if the user has "enabled" editing
const [editing, setEditing] = useState<boolean>(false);
const [localIsDirty, setLocalIsDirty] = useState<boolean>(false);
const user = useUserState();
const [_language] = useLocalState(useShallow((s) => [s.language]));
const [searchParams, setSearchParams] = useSearchParams();
const [markdown, setMarkdown] = useState<string>('');
const [isEditing, setIsEditing] = useState<boolean>(false);
useEffect(() => {
// Initially disable editing mode on load
setEditing(false);
}, [editable, modelId, modelType]);
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
setDirtyCallback?.(localIsDirty);
}, [localIsDirty]);
const [selectedNoteId, setSelectedNoteId] = useState<number | undefined>(
undefined
);
const noteUrl: string = useMemo(() => {
const modelInfo = ModelInformationDict[modelType];
return apiUrl(modelInfo.api_endpoint, modelId);
}, [modelType, modelId]);
// Image upload handler
const imageUploadHandler = useCallback(
(
file: File,
onSuccess: (url: string) => void,
onError: (error: string) => void
) => {
// Callback to upload an image file against the currently selected note
const uploadFile = useCallback(
async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('note', selectedNoteId?.toString() ?? '');
formData.append('image', file);
formData.append('model_type', modelType);
formData.append('model_id', modelId.toString());
api
.post(apiUrl(ApiEndpoints.notes_image_upload), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
return api
.post(apiUrl(ApiEndpoints.notes_image_list), formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.catch((error) => {
onError(error.message);
notifications.hide('notes');
notifications.show({
id: 'notes',
title: t`Error`,
message: t`Image upload failed`,
color: 'red'
});
})
.then((response: any) => {
onSuccess(response.data.image);
notifications.hide('notes');
notifications.show({
id: 'notes',
title: t`Success`,
message: t`Image uploaded successfully`,
color: 'green'
});
});
.then((response) => response.data.image);
},
[modelType, modelId]
[selectedNoteId]
);
const dataQuery = useQuery({
queryKey: ['notes-editor', noteUrl, modelType, modelId],
retry: 5,
queryFn: () =>
api.get(noteUrl).then((response) => response.data?.notes ?? ''),
enabled: true
// Ref so editorProps handlers always call the latest uploadFile without stale closure
const uploadFileRef = useRef(uploadFile);
useEffect(() => {
uploadFileRef.current = uploadFile;
}, [uploadFile]);
const editor = useEditor({
editable: false,
extensions: [
StarterKit.configure({
link: { openOnClick: false }
}),
ResizableImage.configure({
// Paste and drop are handled by the extension's built-in plugin
onUpload: async (file: File) => {
const src = await uploadFileRef.current(file);
return { src, 'data-keep-ratio': true };
}
}),
TableKit.configure({
table: { resizable: false, renderWrapper: true, cellMinWidth: 50 }
})
],
content: '',
onUpdate: () => setIsDirty(true)
});
// Update internal markdown data when the query data changes
useEffect(() => {
setMarkdown(dataQuery.data ?? '');
}, [dataQuery.data]);
// Fetch the available notes for the given model type and ID (or all templates)
const notesQuery = useQuery({
queryKey: ['notes', modelType, modelId, templateMode],
queryFn: async () => {
const params: Record<string, any> = templateMode
? { template: true }
: { model_id: modelId, model_type: modelType };
// Callback to save notes to the server
const saveNotes = useCallback(
(markdown: string) => {
if (!noteUrl) {
return;
return api
.get(apiUrl(ApiEndpoints.note_list), { params })
.then((response) => response.data ?? []);
},
staleTime: 0,
refetchOnWindowFocus: false,
refetchOnMount: true,
enabled: templateMode ? true : !!modelId && !!modelType
});
const [selectedNote, setSelectedNote] = useState<any>(undefined);
const loadNote = useCallback(
(noteId: number) => {
const note = notesQuery.data?.find((note: any) => note.pk === noteId);
setSelectedNote(note);
if (editor && !editor.isDestroyed) {
// Pass emitUpdate:false to avoid triggering dirty state when loading content
editor.commands.setContent(
note ? DOMPurify.sanitize(note.content ?? '') : '',
{ emitUpdate: false }
);
}
api
.patch(noteUrl, { notes: markdown })
.then(() => {
notifications.hide('notes');
notifications.show({
title: t`Success`,
message: t`Notes saved successfully`,
color: 'green',
id: 'notes',
autoClose: 2000
});
setLocalIsDirty(false);
})
.catch((error) => {
notifications.hide('notes');
const msg =
error?.response?.data?.non_field_errors[0] ??
t`Failed to save notes`;
notifications.show({
title: t`Error Saving Notes`,
message: msg,
color: 'red',
id: 'notes'
});
});
setIsDirty(false);
},
[api, noteUrl]
[editor, notesQuery.data]
);
const editorOptions: SimpleMde.Options = useMemo(() => {
const icons: any[] = [];
if (editing) {
icons.push({
name: 'save-notes',
action: (editor: SimpleMde) => {
saveNotes(editor.value());
},
className: 'fa fa-save',
title: t`Save Notes`
});
icons.push('|');
icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings
icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles
icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting
icons.push('table', 'link', 'image', '|');
icons.push('horizontal-rule', '|', 'guide'); // Misc
icons.push('|', 'undo', 'redo'); // Undo/Redo
icons.push('|');
icons.push({
name: 'edit-disabled',
action: () => setEditing(false),
className: 'fa fa-times',
title: t`Close Editor`
});
} else if (editable) {
icons.push({
name: 'edit-enabled',
action: () => setEditing(true),
className: 'fa fa-edit',
title: t`Enable Editing`
});
}
return {
toolbar: icons,
uploadImage: true,
imagePathAbsolute: true,
imageUploadFunction: imageUploadHandler,
renderingConfig: {
sanitizerFunction: (html: string) => {
return DOMPurify.sanitize(html);
}
},
sideBySideFullscreen: false,
shortcuts: {},
spellChecker: false
};
}, [editable, editing]);
const [mdeInstance, setMdeInstance] = useState<SimpleMde | null>(null);
useEffect(() => {
if (mdeInstance) {
const previewMode = !(editable && editing);
// setIsEditing(false);
loadNote(selectedNoteId ?? -1);
}, [editor, selectedNoteId, notesQuery.data]);
mdeInstance.codemirror?.setOption('readOnly', previewMode);
// Adjust the note selection
useEffect(() => {
if (!notesQuery.data) return;
// Ensure the preview mode is toggled if required
if (mdeInstance.isPreviewActive() != previewMode) {
const sibling =
mdeInstance?.codemirror.getWrapperElement()?.nextSibling;
const stillExists =
selectedNoteId &&
notesQuery.data.some((note: any) => note.pk === selectedNoteId);
if (stillExists) return;
if (sibling != null && editable != false) {
EasyMDE.togglePreview(mdeInstance);
}
}
const paramSlug = searchParams.get('note');
const fromParam =
paramSlug &&
notesQuery.data.find(
(note: any) => identifierString(note.title ?? '') === paramSlug
);
if (fromParam) {
setSelectedNoteId(fromParam.pk);
return;
}
}, [mdeInstance, editable, editing]);
const primary = notesQuery.data.find((note: any) => note.primary);
setSelectedNoteId((primary ?? notesQuery.data[0])?.pk ?? undefined);
}, [notesQuery.data]);
const canEdit: boolean = useMemo(
() =>
(templateMode
? user.isStaff()
: modelType
? user.hasChangePermission(modelType)
: false) &&
notesQuery.isFetched &&
notesQuery.isSuccess &&
!!notesQuery.data,
[user, modelType, templateMode, notesQuery]
);
const isInTable = useEditorState({
editor,
selector: ({ editor: e }) => e?.isActive('table') ?? false
});
// Propagate dirty state up to the panel system for navigation guards
useEffect(() => {
setDirtyCallback?.(isDirty);
}, [isDirty, setDirtyCallback]);
// Sync editor editable state when permissions change.
// Pass false for emitUpdate to avoid triggering onUpdate (which sets isDirty).
useEffect(() => {
editor?.setEditable(canEdit && isEditing, false);
}, [editor, canEdit, isEditing]);
const hasNotes = useMemo(() => {
return notesQuery.data && notesQuery.data.length > 0;
}, [notesQuery.data]);
const noteFields = useNoteFields({
modelType: modelType!,
modelId: modelId!
});
const noteTemplateFields = useNoteTemplateFields();
const activeFields = templateMode ? noteTemplateFields : noteFields;
const createNote = useCreateApiFormModal({
title: templateMode ? t`Add Note Template` : t`Add Note`,
fields: activeFields,
url: apiUrl(ApiEndpoints.note_list),
method: 'POST',
successMessage: null,
onFormSuccess: (response: any) => {
notesQuery.refetch().then(() => {
setSelectedNoteId(response.pk);
});
}
});
const deleteNote = useDeleteApiFormModal({
title: templateMode ? t`Delete Note Template` : t`Delete Note`,
url: apiUrl(ApiEndpoints.note_list),
pk: selectedNoteId,
onFormSuccess: () => {
setSelectedNoteId(undefined);
notesQuery.refetch();
}
});
const editNote = useEditApiFormModal({
title: templateMode ? t`Edit Note Template` : t`Edit Note`,
fields: activeFields,
url: apiUrl(ApiEndpoints.note_list),
pk: selectedNoteId,
onFormSuccess: (response: any) => {
notesQuery.refetch().then(() => {
setSelectedNoteId(response.pk);
});
}
});
const reloadNote = useCallback(() => {
loadNote(selectedNoteId ?? -1);
}, [selectedNoteId, loadNote]);
const saveNote = useCallback(() => {
if (!selectedNoteId || !editor) {
return;
}
const cleanHtml = DOMPurify.sanitize(editor.getHTML());
const url = apiUrl(ApiEndpoints.note_list, selectedNoteId);
notifications.hide('note-update-status');
api
.patch(url, { content: cleanHtml })
.then(() => {
setIsDirty(false);
notifications.show({
title: t`Success`,
message: t`Note updated`,
color: 'green',
id: 'note-update-status',
autoClose: 2000
});
})
.catch((error) => {
notifications.show({
title: t`Error`,
message: t`Failed to update note: ${error.message}`,
color: 'red',
id: 'note-update-status',
autoClose: 2000
});
})
.finally(() => {
notesQuery.refetch();
});
}, [selectedNoteId, editor, setIsDirty]);
useHotkeys([['mod+s', saveNote]]);
const handleImageUpload = useCallback(
async (file: File | null) => {
if (!file || !editor) return;
try {
const src = await uploadFile(file);
editor
.chain()
.focus()
.setResizableImage({ src, 'data-keep-ratio': true })
.run();
} catch {
notifications.show({
title: t`Error`,
message: t`Failed to upload image`,
color: 'red',
autoClose: 2000
});
}
},
[editor, uploadFile]
);
return (
<SimpleMDE
autoFocus
getMdeInstance={(instance: SimpleMde) => setMdeInstance(instance)}
onChange={(value: string) => {
setMarkdown(value);
setLocalIsDirty(true);
}}
options={editorOptions}
value={markdown}
/>
<>
{createNote.modal}
{deleteNote.modal}
{editNote.modal}
<Flex align='left' gap={5}>
<Box style={{ flex: 1 }}>
<Stack gap={5}>
{selectedNote && (
<Paper p='xs' shadow='sm' withBorder>
<Group justify='space-between'>
<Group justify='left' gap='lg'>
<NoteInfoHover note={selectedNote} />
<Text fw='bold'>{selectedNote?.title}</Text>
<Text size='sm'>{selectedNote?.description}</Text>
</Group>
{canEdit && (
<Group justify='right' gap='xs'>
{!isEditing && (
<Tooltip label={t`Edit note`}>
<ActionIcon
aria-label='edit-note'
variant='transparent'
onClick={() => setIsEditing(true)}
>
<IconPencil />
</ActionIcon>
</Tooltip>
)}
{isEditing && isDirty && (
<Badge color='yellow'>{t`Unsaved Changes`}</Badge>
)}
{isEditing && isDirty && (
<Tooltip label={t`Save note`}>
<ActionIcon
aria-label='save-note'
variant='transparent'
color={'green'}
onClick={saveNote}
disabled={!canEdit || !isDirty}
>
<IconDeviceFloppy />
</ActionIcon>
</Tooltip>
)}
{isEditing && isDirty && (
<Tooltip label={t`Reset note content`}>
<ActionIcon
aria-label='reset-note'
variant='transparent'
onClick={reloadNote}
disabled={!canEdit || !isDirty}
>
<IconReload />
</ActionIcon>
</Tooltip>
)}
{isEditing && !isDirty && (
<Tooltip label={t`Finish editing`}>
<ActionIcon
aria-label='finish-editing-note'
variant='transparent'
onClick={() => setIsEditing(false)}
color='green'
>
<IconCheck />
</ActionIcon>
</Tooltip>
)}
<OptionsActionDropdown
tooltip={t`Note Actions`}
actions={[
EditItemAction({
hidden: !selectedNote || !canEdit,
onClick: () => {
editNote.open();
}
}),
DeleteItemAction({
hidden:
!selectedNote ||
!(templateMode
? user.isStaff()
: modelType
? user.hasDeletePermission(modelType)
: false),
onClick: () => {
deleteNote.open();
}
})
]}
/>
</Group>
)}
</Group>
</Paper>
)}
<Paper p='xs' shadow='sm' withBorder>
{hasNotes ? (
<RichTextEditor
variant='subtle'
editor={editor}
style={{ minHeight: '400px' }}
data-editing={isEditing || undefined}
>
{canEdit && isEditing && (
<RichTextEditor.Toolbar sticky>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold />
<RichTextEditor.Italic />
<RichTextEditor.Underline />
<RichTextEditor.Strikethrough />
<RichTextEditor.ClearFormatting />
<RichTextEditor.Code />
<RichTextEditor.CodeBlock />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.H1 />
<RichTextEditor.H2 />
<RichTextEditor.H3 />
<RichTextEditor.H4 />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Blockquote />
<RichTextEditor.Hr />
<RichTextEditor.BulletList />
<RichTextEditor.OrderedList />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Link />
<RichTextEditor.Unlink />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<FileButton
onChange={handleImageUpload}
accept='image/*'
>
{(props) => (
<Tooltip label={t`Upload Image`}>
<ActionIcon
variant='default'
size='sm'
{...props}
>
<IconPhoto size='0.9rem' />
</ActionIcon>
</Tooltip>
)}
</FileButton>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Control
onClick={() =>
editor
?.chain()
.focus()
.insertTable({
rows: 3,
cols: 3,
withHeaderRow: true
})
.run()
}
aria-label={t`Insert table`}
title={t`Insert table`}
>
<IconTablePlus size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().addColumnBefore().run()
}
aria-label={t`Add column before`}
title={t`Add column before`}
>
<IconColumnInsertLeft size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().addColumnAfter().run()
}
aria-label={t`Add column after`}
title={t`Add column after`}
>
<IconColumnInsertRight size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().deleteColumn().run()
}
aria-label={t`Delete column`}
title={t`Delete column`}
>
<IconColumnRemove size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().addRowBefore().run()
}
aria-label={t`Add row before`}
title={t`Add row before`}
>
<IconRowInsertTop size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().addRowAfter().run()
}
aria-label={t`Add row after`}
title={t`Add row after`}
>
<IconRowInsertBottom size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().deleteRow().run()
}
aria-label={t`Delete row`}
title={t`Delete row`}
>
<IconRowRemove size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().toggleHeaderRow().run()
}
aria-label={t`Toggle header row`}
title={t`Toggle header row`}
>
<IconTableRow size='0.9rem' />
</RichTextEditor.Control>
<RichTextEditor.Control
disabled={!isInTable}
onClick={() =>
editor?.chain().focus().deleteTable().run()
}
aria-label={t`Delete table`}
title={t`Delete table`}
>
<IconTableOff size='0.9rem' />
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Undo />
<RichTextEditor.Redo />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
)}
<RichTextEditor.Content />
</RichTextEditor>
) : (
<Alert title={t`Notes`} icon={<IconInfoCircle />}>
{t`There are no notes here yet.`}
</Alert>
)}
</Paper>
</Stack>
</Box>
<Paper p='xs' shadow='sm' withBorder style={{ minWidth: '200px' }}>
<Stack gap='xs'>
{canEdit && (
<Button
color='green'
leftSection={<IconCirclePlus />}
onClick={createNote.open}
disabled={isEditing}
>
{t`Add Note`}
</Button>
)}
<Tabs
orientation='vertical'
placement='right'
value={selectedNoteId?.toString()}
>
<Tabs.List style={{ width: '100%' }}>
{notesQuery.data?.map((note: any) => (
<Tabs.Tab
key={note.pk}
disabled={isEditing}
value={note.pk?.toString()}
onClick={() => {
setSelectedNoteId(note.pk);
setSearchParams(
(prev) => {
prev.set('note', identifierString(note.title ?? ''));
return prev;
},
{ replace: true }
);
}}
>
<Group gap='xs' wrap='nowrap' justify='space-between'>
<Text size='sm'>{note.title}</Text>
{note.primary && (
<IconStar
size={14}
color='var(--mantine-color-yellow-6)'
/>
)}
</Group>
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
</Stack>
</Paper>
</Flex>
</>
);
}

View File

@ -4,10 +4,9 @@ import { IconNotes } from '@tabler/icons-react';
import type { ModelType } from '@lib/enums/ModelType';
import type { PanelType } from '@lib/types/Panel';
import { lazy } from 'react';
import { useUserState } from '../../states/UserState';
import NotesEditor from '../editors/NotesEditor';
const NotesEditor = lazy(() => import('../editors/NotesEditor'));
// const NotesEditor = lazy(() => import('../editors/NotesEditor'));
export default function NotesPanel({
model_type,
@ -20,8 +19,6 @@ export default function NotesPanel({
editable?: boolean;
has_note?: boolean;
}): PanelType {
const user = useUserState.getState();
return {
name: 'notes',
label: t`Notes`,
@ -30,11 +27,7 @@ export default function NotesPanel({
notification_dot: has_note ? 'info' : null,
content:
model_type && model_id ? (
<NotesEditor
modelType={model_type}
modelId={model_id}
editable={editable ?? user.hasChangePermission(model_type)}
/>
<NotesEditor modelType={model_type} modelId={model_id} />
) : (
<Skeleton />
),

View File

@ -10,6 +10,7 @@ import {
Stack,
Tabs,
Text,
Title,
Tooltip,
UnstyledButton
} from '@mantine/core';
@ -51,6 +52,7 @@ import type {
} from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { useDocumentVisibility, useWindowEvent } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useShallow } from 'zustand/react/shallow';
import { generateUrl } from '../../functions/urls';
@ -288,21 +290,15 @@ function BasePanelGroup({
[allPanels]
);
// Callback when the active panel changes
const handlePanelChange = useCallback(
const [isDirty, setIsDirty] = useState(false);
useWindowEvent('beforeunload', (event) => {
if (isDirty) {
event.preventDefault();
}
});
const performPanelChange = useCallback(
(targetPanel: string, event?: any) => {
cancelEvent(event);
// check if we are currently on a dirty panel, if so prompt the user to confirm navigation
if (isDirty) {
const confirm = globalThis.confirm(
t`You have unsaved changes, are you sure you want to navigate away from this panel?`
);
if (!confirm) {
return;
}
}
if (event && eventModified(event)) {
const url = `${location.pathname}/../${targetPanel}`;
navigateToLink(url, navigate, event);
@ -312,15 +308,39 @@ function BasePanelGroup({
localState.setLastUsedPanel(pageKey)(targetPanel);
// Optionally call external callback hook
if (targetPanel && onPanelChange) {
onPanelChange(targetPanel);
}
// change dirty state
setIsDirty(false);
},
[activePanels, navigate, location, onPanelChange]
[navigate, location, pageKey, onPanelChange]
);
// Callback when the active panel changes
const handlePanelChange = useCallback(
(targetPanel: string, event?: any) => {
cancelEvent(event);
if (isDirty) {
modals.openConfirmModal({
title: <Title order={4}>{t`Unsaved Changes`}</Title>,
children: (
<>
<Divider />
<Text>{t`You have unsaved changes. Are you sure you want to leave this panel?`}</Text>
</>
),
labels: { confirm: t`Leave`, cancel: t`Stay` },
confirmProps: { color: 'red' },
onConfirm: () => performPanelChange(targetPanel, event)
});
return;
}
performPanelChange(targetPanel, event);
},
[isDirty, performPanelChange]
);
// if the selected panel state changes update the current panel
@ -355,13 +375,6 @@ function BasePanelGroup({
}, [activePanels]);
useInvenTreeHotkeys(hotkeys);
const [isDirty, setIsDirty] = useState(false);
useWindowEvent('beforeunload', (event) => {
if (isDirty) {
event.preventDefault();
}
});
return (
<Boundary label={`PanelGroup-${pageKey}`}>
<Paper p='sm' radius='xs' shadow='xs' aria-label={`${pageKey}`}>

View File

@ -58,6 +58,7 @@ import {
RenderSelectionList,
RenderTag
} from './Generic';
import { RenderNoteTemplate } from './Note';
import {
RenderPurchaseOrder,
RenderReturnOrder,
@ -118,7 +119,8 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
[ModelType.tag]: RenderTag,
[ModelType.notetemplate]: RenderNoteTemplate
};
/**

View File

@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import { RenderInlineModel } from './Instance';
export function RenderNoteTemplate({
instance
}: Readonly<{
instance: any;
}>): ReactNode {
return (
<RenderInlineModel primary={instance.title} suffix={instance.description} />
);
}

View File

@ -139,7 +139,8 @@ export function useBuildOrderFields({
duplicate: DuplicateField({
originalId: duplicateBuildId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};

View File

@ -5,6 +5,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import type {
StatusCodeInterface,
StatusCodeListInterface
@ -303,6 +304,94 @@ export function useParameterFields({
]);
}
export function useNoteTemplateFields(): ApiFormFieldSet {
return useMemo(() => {
return {
template: {
hidden: true,
value: true
},
model_type: {
label: t`Model Type`,
description: t`Limit this template to a specific model type, or leave blank for all models`,
required: false
},
title: {},
description: {}
};
}, []);
}
export function useNoteFields({
modelType,
modelId
}: {
modelType: ModelType;
modelId: number;
}): ApiFormFieldSet {
const api = useApi();
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [content, setContent] = useState<string>('');
const fetchTemplate = useCallback(
(pk: number | null) => {
if (!pk) return;
api
.get(apiUrl(ApiEndpoints.note_list, pk))
.then((response) => {
setTitle(response.data.title ?? '');
setDescription(response.data.description ?? '');
setContent(response.data.content ?? '');
})
.catch(() => {});
},
[api]
);
return useMemo(() => {
return {
model_type: {
hidden: true,
value: modelType
},
model_id: {
hidden: true,
value: modelId
},
template_source: {
field_type: 'related field',
label: t`From Template`,
description: t`Optionally pre-fill this note from an existing template`,
model: ModelType.notetemplate,
api_url: apiUrl(ApiEndpoints.note_list),
filters: {
template: true,
model_type: modelType
},
pk_field: 'pk',
required: false,
onValueChange: (value: any) => fetchTemplate(value),
value: null
},
title: {
value: title,
onValueChange: (value: any) => setTitle(value)
},
description: {
value: description,
onValueChange: (value: any) => setDescription(value)
},
primary: {},
content: {
hidden: true,
value: content
}
};
}, [modelType, modelId, title, description, content, fetchTemplate]);
}
export function selectionListFields(): ApiFormFieldSet {
return {
name: {},

View File

@ -101,7 +101,8 @@ export function useSupplierPartFields({
duplicate: DuplicateField({
originalId: duplicateSupplierPartId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};
@ -146,7 +147,8 @@ export function useManufacturerPartFields({
duplicate: DuplicateField({
originalId: duplicateManufacturerPartId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};
@ -191,7 +193,8 @@ export function companyFields({
duplicate: DuplicateField({
originalId: duplicateCompanyId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};

View File

@ -322,7 +322,8 @@ export function usePurchaseOrderFields({
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}

View File

@ -89,7 +89,8 @@ export function useReturnOrderFields({
value: duplicateOrderId
},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}

View File

@ -103,7 +103,8 @@ export function useSalesOrderFields({
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}

View File

@ -27,6 +27,7 @@ import { modals } from '@mantine/modals';
import {
IconCalendarExclamation,
IconCoins,
IconCopy,
IconCurrencyDollar,
IconLink,
IconPackage,
@ -81,7 +82,8 @@ export function useStockFields({
create = false,
supplierPartId,
pricing,
modalId
modalId,
duplicateStockItem
}: {
partId?: number;
stockItem?: any;
@ -89,6 +91,7 @@ export function useStockFields({
create: boolean;
supplierPartId?: number;
pricing?: { [priceBreak: number]: [number, string] };
duplicateStockItem?: any;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
@ -301,6 +304,25 @@ export function useStockFields({
delete fields.serial_numbers;
}
// Additional fields for stock item duplication
if (create && duplicateStockItem?.pk) {
fields.duplicate = {
icon: <IconCopy />,
children: {
original: {
value: duplicateStockItem.pk,
hidden: true
},
copy_notes: { value: true },
copy_history: { value: false },
copy_tests: {
value: false,
hidden: !duplicateStockItem?.part_detail?.testable
}
}
};
}
return fields;
}, [
stockItem,
@ -315,6 +337,7 @@ export function useStockFields({
purchasePriceCurrency,
serialGenerator.result,
batchGenerator.result,
duplicateStockItem,
create
]);
}

View File

@ -59,7 +59,8 @@ export function useTransferOrderFields({
value: duplicateOrderId
},
copy_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}

View File

@ -15,6 +15,7 @@ import {
IconList,
IconListDetails,
IconMail,
IconNotes,
IconPackages,
IconPhoto,
IconPlugConnected,
@ -72,6 +73,8 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel')));
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
@ -211,6 +214,13 @@ export default function AdminCenter() {
content: <SelectionListTable />,
hidden: !user.hasViewRole(UserRoles.part)
},
{
name: 'notes',
label: t`Note Templates`,
icon: <IconNotes />,
content: <NoteTemplatePanel />,
hidden: !user.isStaff()
},
{
name: 'category-parameters',
label: t`Category Parameters`,
@ -303,6 +313,7 @@ export default function AdminCenter() {
'selection-lists',
'parameters',
'category-parameters',
'notes',
'location-types',
'stocktake'
]

View File

@ -0,0 +1,15 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import NotesEditor from '../../../../components/editors/NotesEditor';
export default function NoteTemplatePanel() {
return (
<Stack gap='xs'>
<Alert color='blue' icon={<IconInfoCircle />} title={t`Note Templates`}>
{t`Note templates can be used to create pre-defined notes which can be easily added to any model instance.`}
</Alert>
<NotesEditor templateMode />
</Stack>
);
}

View File

@ -742,7 +742,8 @@ export default function StockDetail() {
const duplicateStockItemFields = useStockFields({
create: true,
modalId: 'duplicate-stock-item'
modalId: 'duplicate-stock-item',
duplicateStockItem: stockitem
});
const duplicateStockData = useMemo(() => {

View File

@ -1031,23 +1031,61 @@ test('Parts - Test Results', async ({ browser }) => {
});
test('Parts - Notes', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/69/notes' });
const page = await doCachedLogin(browser, { url: 'part/71/details' });
// Enable editing
await page.getByLabel('Enable Editing').waitFor();
await loadTab(page, 'Notes');
// Use keyboard shortcut to "edit" the part
await page.keyboard.press('Control+E');
await page.getByLabel('text-field-name', { exact: true }).waitFor();
await page.getByLabel('text-field-description', { exact: true }).waitFor();
await page.getByLabel('tree-field-category').waitFor();
// Expect to see notes rendered for this part
await page.getByRole('cell', { name: 'Red Widget' }).waitFor();
await page.getByRole('cell', { name: 'Blue Widget' }).waitFor();
await page.getByRole('cell', { name: 'Green Widget' }).waitFor();
await page
.getByRole('link', { name: 'Read more in the documentation' })
.waitFor();
// Let's try to create a new note, but cancel before submitting
await page.getByRole('button', { name: 'Add Note' }).click();
await page.getByLabel('related-field-template').fill('instructions');
await page
.getByRole('option', { name: 'Manufacturing Instructions' })
.click();
await page.getByText('Manufacturing Instructions').waitFor();
await page.getByText('Manufacturing notes specific to this part').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Enable notes editing
await page.getByLabel('Enable Editing').click();
// Enable editing for this note
await page.getByRole('button', { name: 'edit-note' }).click();
await page.getByLabel('Save Notes').waitFor();
await page.getByLabel('Close Editor').waitFor();
await page.getByRole('button', { name: 'Bold' }).waitFor();
await page.getByRole('button', { name: 'Italic' }).waitFor();
await page.getByRole('button', { name: 'Underline' }).waitFor();
await page.getByRole('button', { name: 'Heading 1' }).waitFor();
await page.getByRole('button', { name: 'Heading 2' }).waitFor();
await page.getByRole('button', { name: 'Heading 3' }).waitFor();
await page.getByRole('button', { name: 'finish-editing-note' }).click();
// Duplicate this part - should show options for copying notes
await page.getByRole('button', { name: 'action-menu-part-actions' }).click();
await page
.getByRole('menuitem', { name: 'action-menu-part-actions-duplicate' })
.click();
await page
.getByRole('switch', { name: 'boolean-field-duplicate.copy_notes' })
.waitFor();
// Generate random IPN for copying
const ipn = `IPN-${Math.floor(Math.random() * 100000)}`;
await page.getByRole('textbox', { name: 'text-field-IPN' }).fill(ipn);
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForLoadState('networkidle');
await page.getByText(`Part: ${ipn}`).waitFor();
// Check that the notes have been duplicated to this new part
await loadTab(page, 'Notes');
await page
.getByRole('heading', { name: 'On Widgets (And Variants Thereof)' })
.waitFor();
});
test('Parts - 404', async ({ browser }) => {

File diff suppressed because it is too large Load Diff