[Refactor] Notes (#11971)

* Display note info

* Support user locale

* Add unit testing for HTML content

* Observe color mode

* Add link between Note and NotesImage

* Ensure image file is deleted when NotesImage is deleted

* Add support for image upload in editor

* Skeleton for data migration

* Updates

* Update data migration

- Find any NoteImage items which do not link to a model
- Try to associate them with an existing note

* Remove validator

* Updated API endpoints for NotesImage model

* Update server side sanitizing

* Specify max field length

* Remove old fields from NotesImage model

* Refactor clean_string

* Remove obsolete task

* Remove legacy "notes" field from older models

* Adjust search params when switching notes

* Remove NotesFieldMixin

* Change editor

* Resizable image support

* Support tables

* Add table style

* Adjust header actions

* Add data migration for SalesOrderShipment notes

* Adjust back-end sanitizing

* Adjust

* Use subtle editor variant

* Move undo/redo

* Enhance editing logic

* Add report tags for notes

* Add unit test for note image cleanup

* Render note to HTML

- Automatically replace images

* Fix migration order

* Adjust migration text

* Fix "dirty" trigger on notes

* Prevent navigate from dirty notes

* Add documentation

* Prevent image clicking if not in editing mode

* Update API

* Update migration files

* Fix migrations

* remove notes from test fixtures

* remove notes field that does not exsist anymore

* add missing ruleset

* fix assertation

* fix assertation

* Update docs/docs/concepts/notes.md

Co-authored-by: Matthias Mair <code@mjmair.com>

* Remove blocknote deps

* Move old helper functions

- Only used for this migration
- Will potentially be removed at some point in the future?

* Revert change

* Fix note image URL

* Fix migration conflicts

* Fix migrations

* Fix delete call

* Fix content mixin

* Fix note duplication

* Make save method atomic

* Fix double-save

* Add "template" field to Note model

* Adjust migrations

* Frontend updates

* Fix for NotesEditor

* Render Note instance in forms

* Fix button-within-button

* Fix migrations

* Fix missing import

* add docs

* docs for rendering notes in reports

* Restrict queryset based on user view permissions

* APi unit tests for note permissions

* Duplicate embedded images when copying notes

* Add unit test for note duplication

* Add CHANGELOG

* Add 'copy_note' option to duplicate serializer

* Add unit tests for data migrations

* implement note duplication serializers

* frontend UI elements

* Fix migration conflicts

* Use branch for playwrigh testing

* Implement duplicate action for stock item

* Fix import

* Updated playwright tests

* Bug fix for receiving stock items

* Add screenshot

* Fix api_version

* Update unit tests

* Fix docs

* Remove defunct tests

* Fix migration order

* Adjust import/export workflow

* Manual cleaning update

* Fix migrations

* Fix migration files

* Fix for note save

* Adjust save ordering

* Skip constraint checking in NoteSerializer

* Custom validate_constraints on Note model

* Revert "Skip constraint checking in NoteSerializer"

This reverts commit b42bc955c1.

* Fix for note search

* Fix for receive_line_items

* Shim model renderer for NoteTemplate

* Fix playwright tests

* Adjust frontend CI

* Fix import/export CI job

* Fix for data migration test

* Fix migration test

* Adjust unit test

* Fix conflicting migration

* Fix unit test

* Run migration tests in parallel

* Robustify migration test

* Disable parallel options

* Fix conflicting migration

* Remove extraneous unit test

* Fix conflicting migrations

* Additional regression tests

* Check permissions before deleting Note instance

* Updated docs

* Validate note model type

* Prevent discard of unsaved changes in note editor

* Clean up dead code

* Fix migration conflict

* Improved data migration

* Prefetch role groups

* UI refactoring

* Refactor permission checking code

* Further code refactoring

* use DuplicateField helper

* Refactoring

* Add prefetch

* Throw exception rather than assert

* Logic fix for notes editor

* reimplement old background task

* Adjust data migration

* Fix notes field when receiving items

* Fix existing report templates

* Fix save action for notes editor

* Refactoring: Add "instance-info" API endpoint for common model properties

* Fix indicator dots

* Tweak nav alert msg

* Adjust layout of buttons

* Sanitize notes during migration

* Fix for NotesImage delete cascade

* Fix caching

* Fix race condition in notes editor

* Fix distinct issue when searching notse

* Fix race condition when saving new note instance

* Fix improper error

* Refactor StockItem duplication

* Refactoring

* Increase query time

* Fix api_version.py

* Additional migration tests

* Fix CI workflow

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-08-31 16:46:33 +10:00
committed by GitHub
co-authored by Matthias Mair
parent d39d0262e6
commit 02da01dfec
108 changed files with 6902 additions and 1643 deletions
+1
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.
- [#12507](https://github.com/inventree/InvenTree/pull/12507) calling an invalid or repeated state transition now raises a ValidationError. Plugins implementing state transitions should evaluate the PR and adapt their usage of transitions to gain the new safeguards.
- [#12672](https://github.com/inventree/InvenTree/pull/12672) renames the newly added `tags` filter from 1.4.0 (https://github.com/inventree/InvenTree/pull/12077) to `tag_name` to remove a nameclash.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+147
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.
+2 -2
View File
@@ -193,9 +193,9 @@ Files attachments can be uploaded against the build order, and displayed in the
### Notes
Build order notes (which support markdown formatting) are displayed in the *Notes* tab:
One or more rich-text notes can be attached to a build order, and are displayed in the *Notes* tab.
{{ image("build/build_notes.png", title="Notes") }}
[Read about notes](../concepts/notes.md).
## External Build Orders
+3 -1
View File
@@ -149,4 +149,6 @@ The *Part Attachments* tab displays file attachments associated with the selecte
### Notes
A part may have notes attached, which support markdown formatting.
A part may have one or more rich-text notes attached.
[Read about notes](../concepts/notes.md).
+1 -1
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
+92 -3
View File
@@ -979,9 +979,98 @@ Length: {{ length_value }}
{% endraw %}
```
## Notes
[Notes](../concepts/notes.md) are rich-text documents that can be attached to most InvenTree model instances. Two template tags are available for accessing note content in a report.
### note
The `note` tag returns the rendered HTML content of a note, ready to embed directly in a report. Any images embedded in the note are automatically resolved to their base64-encoded data so that they appear in the generated PDF.
::: report.templatetags.report.note
options:
show_docstring_description: false
show_source: False
If no `title` argument is given, the [primary note](../concepts/notes.md#primary-note) is returned. If a `title` is given, the note whose title matches (case-insensitively) is returned instead. An empty string is returned when no matching note exists.
#### Example
```html
{% raw %}
{% load report %}
<!-- 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 %}
```
## Rendering Markdown
Some data fields (such as the *Notes* field available on many internal database models) support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.
Some data fields (such as those provided by custom plugin models) may support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline.
!!! info "Notes"
[Notes](../concepts/notes.md) content is rich-text (stored as HTML) rather than markdown, and is already sanitized. Use the [note](#note) tag to render it - do not pass it through `markdownify`.
To render markdown content in a report, consider the following simplified example:
@@ -990,9 +1079,9 @@ To render markdown content in a report, consider the following simplified exampl
{% load markdownify %}
<h3>Part Notes</h3>
<h3>Description</h3>
<p>
{{ part.notes | markdownify }}
{{ some_markdown_field | markdownify }}
</p>
{% endraw %}
```
+1
View File
@@ -107,6 +107,7 @@ nav:
- Project Codes: concepts/project_codes.md
- Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md
- Notes: concepts/notes.md
- Tags: concepts/tags.md
- Barcodes:
- Barcode Support: barcodes/index.md
@@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 536
INVENTREE_API_VERSION = 537
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v537 -> 2026-08-31 : 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
v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/xxxx
- Adds SCIM 2 provisioning support
@@ -61,7 +66,7 @@ v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/12310
- Adds new "disassemble" API endpoint for stock items
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12334
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
+2 -2
View File
@@ -100,11 +100,11 @@ class InvenTreeConfig(AppConfig):
def remove_obsolete_tasks(self):
"""Delete any obsolete scheduled tasks in the database."""
obsolete = [
'data_exporter.tasks.cleanup_old_export_outputs',
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
'report.tasks.cleanup_old_report_outputs',
'data_exporter.tasks.cleanup_old_export_outputs',
'stock.tasks.delete_old_stock_items',
]
try:
+34 -1
View File
@@ -37,6 +37,14 @@ class InvenTreeSearchFilter(filters.SearchFilter):
- search_notes: If True, 'notes' is added to the search_fields if it isn't already present
- search_regex: If True, search is performed on 'regex' comparison
"""
from InvenTree.models import InvenTreeNoteMixin
# Set (and read, in filter_queryset) on self rather than returned some other
# way, since get_search_fields() only returns the field list, not the
# queryset - and DRF instantiates a fresh filter backend per request, so
# this doesn't leak state across requests.
self._search_notes_fans_out = False
search_notes = InvenTree.helpers.str2bool(
request.query_params.get('search_notes', False)
)
@@ -45,7 +53,23 @@ class InvenTreeSearchFilter(filters.SearchFilter):
if search_notes and 'notes' not in search_fields:
# don't modify existing list, create a new object so further queries aren't affected
search_fields = [*search_fields, 'notes']
model = view.get_serializer_class().Meta.model
notes_field: str = ''
if issubclass(model, InvenTreeNoteMixin):
notes_field = 'notes_list__content'
elif hasattr(model, 'notes'):
notes_field = 'notes'
if notes_field:
search_fields = [*search_fields, notes_field]
# notes_list__content traverses a reverse one-to-many relation (an
# instance can have multiple notes) - the queryset needs
# deduplicating afterwards, or an instance with 2+ matching notes
# is returned once per matching note instead of once overall.
self._search_notes_fans_out = notes_field == 'notes_list__content'
regex = InvenTree.helpers.str2bool(
request.query_params.get('search_regex', False)
@@ -62,6 +86,15 @@ class InvenTreeSearchFilter(filters.SearchFilter):
return fields
def filter_queryset(self, request, queryset, view):
"""Apply the search filter, then deduplicate if notes search fanned out the join."""
queryset = super().filter_queryset(request, queryset, view)
if getattr(self, '_search_notes_fans_out', False):
queryset = queryset.distinct()
return queryset
def get_search_terms(self, request):
"""Return the search terms for this search request.
@@ -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')
@@ -954,63 +948,6 @@ def remove_non_printable_characters(value: str, remove_newline=True) -> str:
return cleaned
def clean_markdown(value: str) -> str:
"""Clean a markdown string.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
try:
markdownify_settings = settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
markdownify_settings = {}
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
# Generate raw HTML from provided markdown (without sanitizing)
# Note: The 'html' output_format is required to generate self closing tags, e.g. <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.
+3 -37
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
@@ -58,38 +52,10 @@ class CleanBase:
"""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:
+155 -48
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
@@ -667,14 +666,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.
@@ -682,6 +681,159 @@ 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__)
# Prefetch each note's images in a single extra query, rather than
# one 'images.all()' query per note.
#
# Sort so primary note is saved last — Note.save() promotes the last
# note saved with primary=True, which correctly mirrors the source.
# This (and the resulting demotion of sibling notes) is real business
# logic in Note.save(), so notes must still be saved one at a time,
# in this order - unlike common.migrations.0051's data migration,
# which bulk_create()s notes directly, this can't do the same: that
# migration operates on a historical model with no custom
# save()/clean() methods at all, so there's no primary-flag logic to
# preserve there in the first place.
source_notes = sorted(
other.notes.all().prefetch_related('images'), key=lambda n: n.primary
)
for source_note in source_notes:
new_note = common.models.Note(
model_type=content_type,
model_id=self.pk,
primary=source_note.primary,
title=source_note.title,
description=source_note.description,
content=source_note.content,
)
new_note.save()
# Read each source image's file data and write it to storage up front,
# then bulk_create() all of this note's NotesImage rows in one INSERT
# instead of one save() per image - unlike Note, NotesImage has no
# save()-time business logic, so this is safe to batch.
new_images = []
for img in source_note.images.all():
if not img.image:
continue
old_url = img.image.url
filename = os.path.basename(img.image.name)
try:
img.image.open('rb')
data = img.image.read()
finally:
img.image.close()
new_img = common.models.NotesImage(note=new_note, user=img.user)
# save=False: still writes the file to storage (and assigns the
# resulting name/url), but defers the NotesImage row itself to
# the bulk_create() below
new_img.image.save(filename, ContentFile(data), save=False)
new_images.append((old_url, new_img))
if new_images:
common.models.NotesImage.objects.bulk_create([
new_img for _, new_img in new_images
])
content_updated = False
for old_url, new_img in new_images:
if old_url in new_note.content:
new_note.content = new_note.content.replace(
old_url, new_img.image.url
)
content_updated = True
if content_updated:
new_note.save()
@property
def primary_note(self):
"""Return the primary note for this model instance, if it exists."""
return self.notes_list.all().order_by('-primary').first()
def get_note(self, title: Optional[str] = None):
"""Return a Note instance for the given note title.
Arguments:
title: Title of the note to retrieve. If None, returns the primary note (if it exists)
"""
notes = self.notes_list.all().order_by('-primary')
if title:
notes = notes.filter(title=title)
return notes.first()
def check_note_delete(self, note) -> bool:
"""Run a check to determine if the provided note can be deleted.
The default implementation always returns True, but this can be overridden in the implementing class.
"""
return True
def check_note_save(self, note) -> bool:
"""Run a check to determine if the provided note can be saved.
The default implementation always returns True, but this can be overridden in the implementing class.
"""
return True
class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin):
"""Provides an abstracted class for managing file attachments.
@@ -1321,51 +1473,6 @@ 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.
+2 -2
View File
@@ -224,7 +224,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',
@@ -239,7 +239,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',
+1 -1
View File
@@ -81,7 +81,7 @@ def report_exception(exc, scope: Optional[dict] = None): # pragma: no cover
if any(isinstance(exc, e) for e in sentry_ignore_errors()):
return
# Error may also be passed in from the loggingn context
# Error may also be passed in from the logging context
if hasattr(exc, 'event'):
event = getattr(exc, 'event', None)
+35 -35
View File
@@ -21,7 +21,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
@@ -1001,30 +1000,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'):
request = self.context.get('request', None)
method = getattr(request, 'method', None)
if view := self.context.get('view', None):
if (
issubclass(view.__class__, ListModelMixin)
and method in SAFE_METHODS
and not InvenTree.ready.isGeneratingSchema()
):
self.fields.pop('notes', None)
class ContentTypeField(serializers.ChoiceField):
"""Serializer field which represents a ContentType as 'app_label.model_name'.
@@ -1131,19 +1106,13 @@ class DuplicateOptionsSerializer(serializers.Serializer):
'copy_parameters',
_('Copy Parameters'),
_('Copy parameters from the original item'),
False,
),
(
'copy_lines',
_('Copy Lines'),
_('Copy line items from the original order'),
False,
),
('copy_notes', _('Copy Notes'), _('Copy notes from the original item')),
('copy_lines', _('Copy Lines'), _('Copy line items from the original order')),
(
'copy_extra_lines',
_('Copy Extra Lines'),
_('Copy extra line items from the original order'),
False,
),
]
@@ -1177,8 +1146,8 @@ 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
@@ -1217,3 +1186,34 @@ class DuplicateOptionsSerializer(serializers.Serializer):
label=spec.get('label', spec['name']),
help_text=spec.get('help_text', ''),
)
def apply_duplicate_copy_options(
instance, duplicate: dict, original, **copy_defaults: bool
) -> None:
"""Apply the standard 'copy_<x>' duplicate-options onto a newly duplicated instance.
Many serializers which support duplication (Part/Company/ManufacturerPart/SupplierPart/
Build/PurchaseOrder/SalesOrder/ReturnOrder/TransferOrder/SalesOrderShipment) expose a set
of 'copy_<x>' boolean flags (e.g. copy_notes, copy_parameters) which each map onto an
identically-named `instance.copy_<x>_from(original)` method. This is the shared dispatch
for that convention, so adding a new flag - e.g. a future copy_attachments, once
InvenTreeAttachmentMixin grows a copy_attachments_from() method - is a one-line addition
at each call site rather than a new copy-pasted `if duplicate.get(...): instance.copy_..._
from(...)` block. Any duplicate flag whose target method doesn't follow the
copy_<x>_from() naming convention (e.g. Part's copy_bom/copy_image/copy_tests, or
StockItem's copy_history/copy_tests) must still be handled separately by the caller.
Arguments:
instance: The newly created instance to copy data onto
duplicate: The validated 'duplicate' options dict - callers are expected to have
already checked `if duplicate:` before calling this (and extracted `original`
from it), since they still need both to handle their own additional flags
original: The source instance to copy data from
**copy_defaults: One kwarg per 'copy_<x>' flag to apply, e.g.
`copy_notes=True, copy_parameters=True` - the kwarg's value is the default used
if that flag isn't present in `duplicate`
"""
for flag, default in copy_defaults.items():
if duplicate.get(flag, default):
getattr(instance, f'{flag}_from')(original)
+53 -2
View File
@@ -400,6 +400,9 @@ class SearchTests(InvenTreeAPITestCase):
def test_search_filters(self):
"""Test that the regex, whole word, and notes filters are handled correctly."""
from build.models import Build
from common.models import Note
SEARCH_TERM = 'some note'
RE_SEARCH_TERM = 'some (.*) note'
@@ -408,10 +411,20 @@ class SearchTests(InvenTreeAPITestCase):
{'search': SEARCH_TERM, 'limit': 10, 'part': {}, 'build': {}},
expected_code=200,
)
# No build or part results
self.assertEqual(response.data['build']['count'], 0)
self.assertEqual(response.data['part']['count'], 0)
# Add a "note" to a build
build = Build.objects.first()
_note = Note.objects.create(
content='<html><body>some note</body></html>',
model_id=build.id,
model_type=build.get_content_type(),
)
# add the search_notes param
response = self.post(
reverse('api-search'),
@@ -424,8 +437,9 @@ class SearchTests(InvenTreeAPITestCase):
},
expected_code=200,
)
# now should have some build results
self.assertEqual(response.data['build']['count'], 4)
self.assertEqual(response.data['build']['count'], 1)
# use the regex term
response = self.post(
@@ -456,7 +470,7 @@ class SearchTests(InvenTreeAPITestCase):
expected_code=200,
)
# we get our results back!
self.assertEqual(response.data['build']['count'], 4)
self.assertEqual(response.data['build']['count'], 1)
# add the search_whole param
response = self.post(
@@ -474,6 +488,43 @@ class SearchTests(InvenTreeAPITestCase):
# No results again
self.assertEqual(response.data['build']['count'], 0)
def test_search_notes_distinct(self):
"""Test that search_notes does not return duplicate results for multi-note instances.
notes_list__content traverses a reverse one-to-many relation (an instance can have
multiple notes) - without deduplicating the queryset, an instance with 2+ matching
notes is returned once per matching note instead of once overall.
"""
from build.models import Build
from common.models import Note
SEARCH_TERM = 'multi note match'
build = Build.objects.first()
content_type = build.get_content_type()
# Two separate notes on the same build, both matching the search term
Note.objects.create(
content=f'<p>first {SEARCH_TERM}</p>',
model_id=build.id,
model_type=content_type,
)
Note.objects.create(
content=f'<p>second {SEARCH_TERM}</p>',
model_id=build.id,
model_type=content_type,
)
response = self.post(
reverse('api-search'),
{'search': SEARCH_TERM, 'limit': 10, 'search_notes': True, 'build': {}},
expected_code=200,
)
# The build must be returned exactly once, not once per matching note
self.assertEqual(response.data['build']['count'], 1)
self.assertEqual(len(response.data['build']['results']), 1)
def test_permissions(self):
"""Test that users with insufficient permissions are handled correctly."""
# First, remove all roles
@@ -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
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-25 12:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("build", "0059_build_tags"),
("common", "0052_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="build",
name="notes",
),
]
+1 -1
View File
@@ -88,8 +88,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,
+11 -11
View File
@@ -41,9 +41,9 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
PrefetchSpec,
apply_duplicate_copy_options,
)
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
@@ -63,7 +63,6 @@ from .validators import check_build_output
class BuildSerializer(
CustomStatusSerializerMixin,
FilterableSerializerMixin,
NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
@@ -105,7 +104,6 @@ class BuildSerializer(
'status_custom_key',
'target_date',
'take_from',
'notes',
'link',
'issued_by',
'issued_by_detail',
@@ -197,7 +195,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."""
@@ -213,10 +213,13 @@ class BuildSerializer(
instance = super().create(validated_data)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
apply_duplicate_copy_options(
instance,
duplicate,
duplicate['original'],
copy_notes=True,
copy_parameters=True,
)
return instance
@@ -1558,12 +1561,9 @@ 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',
)
+57
View File
@@ -11,6 +11,7 @@ from rest_framework import status
from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus
from common.models import Note
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
@@ -608,6 +609,62 @@ class BuildTest(BuildAPITest):
self.assertIsNotNone(bo.issued_by)
self.assertEqual(bo.issued_by, self.user)
def test_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a Build via the API.
BuildSerializer declares its 'duplicate' options with copy_notes=True,
so notes should be copied by default (i.e. without explicitly requesting it).
"""
from django.contrib.contenttypes.models import ContentType
url = reverse('api-build-list')
part = Part.objects.create(
name='Duplicate Notes Assembly', description='x', assembly=True
)
original = Build.objects.create(
part=part, reference='BO-9001', title='Original build', quantity=5
)
Note.objects.create(
model_type=ContentType.objects.get_for_model(Build),
model_id=original.pk,
title='Original Note',
content='<p>Some build notes</p>',
)
response = self.post(
url,
{
'reference': 'BO-9002',
'part': part.pk,
'quantity': 5,
'title': 'Duplicate build',
'duplicate': {'original': original.pk},
},
expected_code=201,
)
new_build = Build.objects.get(pk=response.data['pk'])
self.assertEqual(new_build.notes.count(), 1)
self.assertEqual(new_build.notes.first().content, '<p>Some build notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'reference': 'BO-9003',
'part': part.pk,
'quantity': 5,
'title': 'Duplicate build without notes',
'duplicate': {'original': original.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_build = Build.objects.get(pk=response.data['pk'])
self.assertEqual(no_notes_build.notes.count(), 0)
class BuildAllocationTest(BuildAPITest):
"""Unit tests for allocation of stock items against a build order.
+9
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."""
+216 -4
View File
@@ -467,17 +467,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)
@@ -910,6 +931,105 @@ 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.
"""
import common.validators
from users.permissions import check_user_permission, prefetch_rule_sets
qs = super().get_queryset()
user = self.request.user
if user.is_superuser:
return qs
# Fetch the user's groups (with prefetched rule sets) once, and reuse it
# for every model type below - otherwise each check_user_permission()
# call re-fetches the same groups/rule-sets from scratch.
groups = prefetch_rule_sets(user)
allowed_ct_ids = [
ContentType.objects.get_for_model(model_class).pk
for model_class in common.validators.note_model_types()
if check_user_permission(user, model_class, 'view', groups=groups)
]
return qs.filter(Q(template=True) | Q(model_type__in=allowed_ct_ids))
class NoteList(NoteMixin, ListCreateAPI):
"""List API endpoint for Note objects."""
filter_backends = SEARCH_ORDER_FILTER
filterset_class = NoteFilter
ordering = '-primary'
ordering_fields = [
'model_id',
'model_type',
'updated_by',
'updated',
'primary',
'template',
'title',
]
search_fields = ['title', 'description', 'content']
class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Note objects."""
def perform_destroy(self, instance):
"""Enforce the same permission rules on delete as on create/update.
DRF's default destroy() calls instance.delete() directly, bypassing
NoteSerializer.save() (and the permission checks it performs) entirely.
Without this, get_queryset()'s 'view' permission gate is all that
stands between a user and deleting the note.
"""
common.serializers.check_note_change_permission(
self.request.user,
template=instance.template,
model_type=instance.model_type,
)
super().perform_destroy(instance)
class ParameterTemplateFilter(FilterSet):
"""FilterSet class for the ParameterTemplateList API endpoint."""
@@ -1231,6 +1351,82 @@ class ParameterDetail(ParameterMixin, RetrieveUpdateDestroyAPI):
"""Detail API endpoint for Parameter objects."""
class InstanceInfoView(APIView):
"""Return aggregated attachment/note/parameter counts for a single model instance.
A single generic lookup (given a model_type + model_id) for any detail page to
drive its Attachments/Notes/Parameters tab notification dots from one request,
instead of each tab independently querying its own list endpoint just to read
a count.
Each count reuses the filtering (and, for notes, the view-permission gating)
already implemented by the corresponding list endpoint.
"""
permission_classes = [IsAuthenticatedOrReadScope]
@extend_schema(
parameters=[
OpenApiParameter(name='model_type', type=str, required=True),
OpenApiParameter(name='model_id', type=int, required=True),
],
responses={200: common.serializers.InstanceInfoSerializer},
)
def get(self, request, *args, **kwargs):
"""Return counts of attachments, notes and parameters for the given instance."""
from InvenTree.models import (
InvenTreeAttachmentMixin,
InvenTreeNoteMixin,
InvenTreeParameterMixin,
)
model_type = request.query_params.get('model_type')
model_id = request.query_params.get('model_id')
if not model_type or not model_id:
raise ValidationError({
'model_type': _('This field is required'),
'model_id': _('This field is required'),
})
try:
model_id = int(model_id)
except (TypeError, ValueError):
raise ValidationError({'model_id': _('Invalid model ID')})
content_type = common.filters.determine_content_type(model_type)
model_class = content_type.model_class() if content_type else None
counts = {'attachment_count': 0, 'note_count': 0, 'parameter_count': 0}
if model_class:
if issubclass(model_class, InvenTreeAttachmentMixin):
counts['attachment_count'] = common.models.Attachment.objects.filter(
model_type=model_class.__name__.lower(), model_id=model_id
).count()
if issubclass(model_class, InvenTreeNoteMixin):
# Route through NoteList's own get_queryset() (rather than
# re-deriving the view-permission check here) so this count can
# never drift from what the Notes list endpoint actually shows.
note_list_view = NoteList()
note_list_view.request = request
counts['note_count'] = (
note_list_view
.get_queryset()
.filter(model_type=content_type, model_id=model_id, template=False)
.count()
)
if issubclass(model_class, InvenTreeParameterMixin):
counts['parameter_count'] = common.models.Parameter.objects.filter(
model_type=content_type, model_id=model_id
).count()
serializer = common.serializers.InstanceInfoSerializer(counts)
return Response(serializer.data)
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
class IconList(ListAPI):
"""List view for available icon packages."""
@@ -1538,8 +1734,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/',
@@ -1571,6 +1765,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/',
@@ -1606,6 +1816,8 @@ common_api_urls = [
path('', ParameterList.as_view(), name='api-parameter-list'),
]),
),
# Aggregated per-instance counts (attachments / notes / parameters)
path('instance-info/', InstanceInfoView.as_view(), name='api-instance-info'),
# Metadata
path(
'metadata/',
@@ -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),
),
]
@@ -0,0 +1,149 @@
# Generated by Django 5.2.14 on 2026-05-18 14:16
import common.validators
import InvenTree.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0049_notificationentry_charfield_uid"),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Note",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"metadata",
models.JSONField(
blank=True,
help_text="JSON metadata field, for use by external plugins",
null=True,
verbose_name="Plugin Metadata",
),
),
(
"updated",
models.DateTimeField(
blank=True,
default=None,
help_text="Timestamp of last update",
null=True,
verbose_name="Updated",
),
),
("model_id", models.PositiveIntegerField(
blank=True,
null=True,
help_text="Target model instance ID for this note",
)),
(
"title",
models.CharField(
help_text="Note title", max_length=100, verbose_name="Title",
),
),
(
"description",
models.CharField(
blank=True,
help_text="Optional description field",
max_length=250,
verbose_name="Description",
),
),
(
"content",
models.TextField(
blank=True, help_text="Note content", verbose_name="Content", max_length=50000
),
),
(
"model_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
help_text="Target model type for this note",
blank=True,
null=True,
validators=[common.validators.validate_note_model_type]
),
),
(
"template",
models.BooleanField(
default=False,
help_text="Is this note a template (not linked to a specific model instance)?",
verbose_name="Template",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
help_text="User who last updated this object",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated",
to=settings.AUTH_USER_MODEL,
verbose_name="Update By",
),
),
(
"primary",
models.BooleanField(
default=False,
help_text="Is this the primary note for the associated model?",
verbose_name="Primary",
),
)
],
options={
"verbose_name": "Note",
"verbose_name_plural": "Notes",
},
bases=(
InvenTree.models.ContentTypeMixin,
InvenTree.models.PluginValidationMixin,
models.Model,
),
),
# Once the 'Note' model has been created, we can add the foreign key to the 'NotesImage' model
# This will (initially) allow null values, so that existing images are not affected
# After the data migration, we will come back and mark this field as non-nullable
migrations.AddField(
model_name="notesimage",
name="note",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="common.note",
related_name='images',
),
),
# Add constraint to ensure that only one 'primary' note exists per model instance
migrations.AddConstraint(
model_name="note",
constraint=models.UniqueConstraint(
condition=models.Q(("primary", True), ("template", False)),
fields=("model_type", "model_id"),
name="unique_primary_note_per_model",
),
),
]
@@ -0,0 +1,300 @@
# Generated by Django 5.2.14 on 2026-05-25 09:56
import copy
import re
import nh3
from tqdm import tqdm
from django.db import migrations
# Number of instances processed per Note.bulk_create() / NotesImage.bulk_update() call
BATCH_SIZE = 500
def get_markdownify_settings() -> dict:
"""Return the settings for markdownify, or an empty dict if not defined."""
from django.conf import settings
try:
return settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
return {}
def markdown_to_html(value: str) -> str:
"""Convert a markdown string to HTML.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
markdownify_settings = get_markdownify_settings()
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
html = markdown.markdown(
value or '',
extensions=extensions,
extension_configs=extension_configs,
output_format='html',
)
return html
def sanitize_note_content(value: str) -> str:
"""Strip unsafe HTML from a migrated note's content.
Mirrors the nh3 allowlist/filtering in common.models.Note.clean() - duplicated rather than
imported so this migration stays self-contained and unaffected by future changes to the live
model (same reasoning as markdown_to_html() above). This is the only write path for Note content
that doesn't go through Note.save()/.clean() (bulk_create() skips both), so without this, a
legacy 'notes' field containing pre-existing raw HTML would be copied into Note.content unsanitized.
"""
if not value:
return value
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'})
cleaned = nh3.clean(
value.strip(),
attributes=attrs,
filter_style_properties={
'color', 'background-color', 'font-size', 'font-weight', 'font-style',
'font-family', 'text-decoration', 'text-align', 'border', 'border-color',
'border-style', 'border-width', 'margin', 'padding', 'column-width',
'column-height', 'min-width', 'max-width', 'min-height', 'max-height',
'width', 'height',
},
)
# nh3 does not recognise legacy IE-only CSS expression() calls as
# unsafe, so they survive style attribute filtering - strip them explicitly
return re.sub(r'expression\s*\(', '', cleaned, flags=re.IGNORECASE)
def create_notes_batch(Note, NotesImage, content_type, model, instances, unlinked_images):
"""Create Note objects (and link any associated images) for a batch of instances.
Issues a single bulk_create() for the notes, a single query + bulk_update() for
directly-linked images, and a single bulk_update() for images embedded in the
markdown content - instead of one Note.objects.create() and two image queries
*per instance*, which does not scale to tables with large numbers of notes.
`unlinked_images` is a shared list of not-yet-linked NotesImage objects, computed
once for the whole migration. Matched images are removed from it in place, so an
image can only ever be claimed by one note, matching the original per-instance
query's behaviour (each query only ever saw images not yet linked by a previous
instance).
"""
notes = Note.objects.bulk_create(
[
Note(
title="Note", # We don't have a title field in the old model, so we'll just use a default value
content=sanitize_note_content(markdown_to_html(instance.notes)),
model_type=content_type,
model_id=instance.pk,
primary=True,
)
for instance in instances
],
batch_size=BATCH_SIZE,
)
notes_by_model_id = {instance.pk: note for instance, note in zip(instances, notes)}
# Images directly linked to one of these instances
direct_images = list(
NotesImage.objects.filter(
model_type__iexact=model, model_id__in=list(notes_by_model_id)
)
)
for image in direct_images:
image.note = notes_by_model_id[image.model_id]
# Images not directly linked to any instance, but still referenced in the
# markdown content itself
embedded_images = []
for instance, note in zip(instances, notes):
matched = [
image for image in unlinked_images if image.image.url in instance.notes
]
for image in matched:
image.note = note
embedded_images.append(image)
unlinked_images.remove(image)
updated_images = direct_images + embedded_images
if updated_images:
NotesImage.objects.bulk_update(updated_images, ['note'], batch_size=BATCH_SIZE)
return notes
def migrate_orphaned_images(Note, NotesImage, content_type, model):
"""Preserve any still-unlinked, directly-attached images for the given model.
create_notes_batch() only processes instances whose legacy 'notes' field is
non-empty (there's no note content to migrate for a blank one), so a directly
linked NotesImage (model_type/model_id set at upload time, independent of
whatever the 'notes' field currently contains) attached to a blank-notes
instance is never picked up by it and would otherwise be silently discarded
by remove_unlinked_images() at the end of this migration.
Rather than losing these images, create one empty, primary Note per affected
instance to hold them. The 'delete_old_notes_images' scheduled task (see
common.tasks) already handles cleaning up images which remain unreferenced
in their note's content once they age out - same as it always did before
this refactor - so nothing further needs to happen here.
"""
orphaned_images = list(
NotesImage.objects.filter(model_type__iexact=model, note__isnull=True)
)
if not orphaned_images:
return
model_ids = sorted({image.model_id for image in orphaned_images})
notes = Note.objects.bulk_create(
[
Note(
title="Note",
content='',
model_type=content_type,
model_id=model_id,
primary=True,
)
for model_id in model_ids
],
batch_size=BATCH_SIZE,
)
notes_by_model_id = dict(zip(model_ids, notes))
for image in orphaned_images:
image.note = notes_by_model_id[image.model_id]
NotesImage.objects.bulk_update(orphaned_images, ['note'], batch_size=BATCH_SIZE)
def migrate_notes(apps, schema_editor):
"""Migrate existing notes to the new Note model."""
ContentType = apps.get_model("contenttypes", "ContentType")
# New target models
Note = apps.get_model('common', 'Note')
NotesImage = apps.get_model('common', 'NotesImage')
# Images not yet linked to any note, and not directly tied to a model instance -
# candidates for the "embedded in markdown content" match in create_notes_batch().
# Computed once for the whole migration (matched images are removed as they're
# claimed), rather than being re-queried and re-scanned from scratch for every
# single row being migrated.
unlinked_images = list(
NotesImage.objects.filter(note__isnull=True, model_id__isnull=True).exclude(
image__isnull=True
)
)
for app, model in [
('build', 'build'),
('company', 'company'),
('company', 'manufacturerpart'),
('company', 'supplierpart'),
('order', 'purchaseorder'),
('order', 'returnorder'),
('order', 'salesorder'),
('order', 'salesordershipment'),
('order', 'transferorder'),
('part', 'part'),
('stock', 'stockitem'),
]:
# Find old model which contains the 'notes' field
OldModel = apps.get_model(app, model)
with_notes = OldModel.objects.exclude(notes__isnull=True).exclude(notes='')
content_type, _created = ContentType.objects.get_or_create(app_label=app, model=model)
total = with_notes.count()
if total:
progress = tqdm(total=total, desc=f'Migration common.0051: Migrating notes for {app}.{model}')
created = 0
batch = []
for instance in with_notes.iterator(chunk_size=BATCH_SIZE):
batch.append(instance)
if len(batch) >= BATCH_SIZE:
created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images))
progress.update(len(batch))
batch = []
if batch:
created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images))
progress.update(len(batch))
if created != total:
raise RuntimeError(
f'Expected to create {total} notes for {app}.{model}, but created {created}.'
)
# Handle any remaining directly-linked images for instances with blank
# notes - not covered by the with_notes loop above, so this must run
# even when total == 0 (i.e. no instance of this model has any notes
# text at all, but some may still have directly-attached images).
migrate_orphaned_images(Note, NotesImage, content_type, model)
def remove_unlinked_images(apps, schema_editor):
"""Remove any NoteImage objects which are not linked to a Note instance."""
NotesImage = apps.get_model('common', 'NotesImage')
unlinked_images = NotesImage.objects.filter(note__isnull=True)
for image in unlinked_images:
image.delete()
class Migration(migrations.Migration):
# Ensure that each app which supports 'notes' is up-to-date first
dependencies = [
("common", "0050_note"),
# Other internal apps which have models that support notes
("build", "0059_build_tags"),
("company", "0080_company_tags"),
("order", "0121_add_line_item_discount"),
("part", "0152_alter_partpricing_currency"),
("stock", "0125_remove_mptt_fields")
]
operations = [
migrations.RunPython(
code=migrate_notes,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=remove_unlinked_images,
reverse_code=migrations.RunPython.noop,
)
]
@@ -0,0 +1,31 @@
# Generated by Django 5.2.14 on 2026-05-25 12:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0051_auto_20260525_0956"),
]
operations = [
migrations.RemoveField(
model_name="notesimage",
name="model_id",
),
migrations.RemoveField(
model_name="notesimage",
name="model_type",
),
migrations.AlterField(
model_name="notesimage",
name="note",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="common.note",
),
),
]
+341 -45
View File
@@ -4,11 +4,13 @@ These models are 'generic' and do not fit a particular business logic object.
"""
import base64
import copy
import hashlib
import hmac
import json
import math
import os
import re
import uuid
from collections import OrderedDict
from datetime import timedelta, timezone
@@ -42,6 +44,7 @@ from django.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
@@ -1786,42 +1789,6 @@ class NewsFeedEntry(models.Model):
)
def rename_notes_image(instance, filename):
"""Function for renaming uploading image file. Will store in the 'notes' directory."""
fname = os.path.basename(filename)
return os.path.join('notes', fname)
class NotesImage(models.Model):
"""Model for storing uploading images for the 'notes' fields of various models.
Simply stores the image file, for use in the 'notes' field (of any models which support markdown).
"""
image = models.ImageField(
upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file')
)
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
date = models.DateTimeField(auto_now_add=True)
model_type = models.CharField(
max_length=100,
blank=True,
null=True,
validators=[common.validators.validate_notes_model_type],
help_text=_('Target model type for this image'),
)
model_id = models.IntegerField(
help_text=_('Target model ID for this image'),
blank=True,
null=True,
default=None,
)
class CustomUnit(models.Model):
"""Model for storing custom physical unit definitions.
@@ -3027,10 +2994,10 @@ class Parameter(
"""
from InvenTree.models import InvenTreeParameterMixin
try:
instance = self.content_object
except InvenTree.models.InvenTreeModel.DoesNotExist:
return
# content_object is None (rather than raising) if the target row is
# missing - GenericForeignKey.__get__ catches ObjectDoesNotExist
# internally, it never propagates it.
instance = self.content_object
if instance and isinstance(instance, InvenTreeParameterMixin):
instance.check_parameter_save(self)
@@ -3039,15 +3006,11 @@ class Parameter(
"""Check if this parameter can be deleted."""
from InvenTree.models import InvenTreeParameterMixin
try:
instance = self.content_object
except InvenTree.models.InvenTreeModel.DoesNotExist:
return
instance = self.content_object
if instance and isinstance(instance, InvenTreeParameterMixin):
instance.check_parameter_delete(self)
# TODO: Reintroduce validator for model_type
model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
model_id = models.PositiveIntegerField(
@@ -3097,6 +3060,339 @@ 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')
def validate_constraints(self, exclude=None):
"""Validate model constraints, skipping 'unique_primary_note_per_model'.
That constraint is actively maintained by save() (which demotes any
sibling primary note before saving self), so checking it here against
pre-save DB state would incorrectly reject legitimate primary-flag
promotions that save() would otherwise handle correctly.
"""
constraints = [
c
for c in self._meta.constraints
if c.name != 'unique_primary_note_per_model'
]
errors = {}
for constraint in constraints:
try:
constraint.validate(self.__class__, self, exclude=exclude)
except ValidationError as e:
errors = e.update_error_dict(errors)
if errors:
raise ValidationError(errors)
@transaction.atomic
def save(self, *args, **kwargs):
"""Perform custom save checks before saving a Note instance."""
self.check_save()
if not self.template:
is_create = self.pk is None
# Lock sibling notes to serialize concurrent primary-flag updates.
# This only has rows to lock once at least one sibling already
# exists - it cannot lock a row that doesn't exist yet, so it does
# *not* by itself serialize the very first note being created for
# a given model instance (see the is_create handling below).
siblings = (
Note.objects
.select_for_update()
.filter(
model_type=self.model_type, model_id=self.model_id, template=False
)
.exclude(pk=self.pk)
)
# If this is the *only* note for this model instance, set it as primary
if not siblings.exists():
self.primary = True
# Demote sibling notes *before* saving self, so that the partial unique
# constraint on (model_type, model_id, primary=True) is never briefly
# violated by two rows with primary=True existing at once
if self.primary:
siblings.update(primary=False)
self.clean()
if is_create and self.primary:
# Phantom-row race: two concurrent creates of the first note for
# the same model instance can both reach here believing they're
# the only (and thus primary) one, since select_for_update()
# above had no existing sibling row to lock either of them
# against. Let the DB's own unique_primary_note_per_model
# constraint arbitrate instead - retry as a non-primary note if
# we lost the race, rather than surfacing a raw IntegrityError.
# A savepoint is required so a failed attempt only rolls back
# this insert, not the whole (outer) atomic transaction.
try:
with transaction.atomic():
super().save(*args, **kwargs)
except IntegrityError:
self.primary = False
super().save(*args, **kwargs)
else:
super().save(*args, **kwargs)
else:
# Templates skip primary-flag logic entirely
self.primary = False
self.clean()
super().save(*args, **kwargs)
self.cleanup_images()
def clean(self):
"""Clean / validate the note before saving to the database."""
from django.core.exceptions import ValidationError
if not self.template:
if not self.model_type:
raise ValidationError({'model_type': _('This field is required.')})
if self.model_id is None:
raise ValidationError({'model_id': _('This field is required.')})
if self.model_type:
try:
common.validators.validate_note_model_type(self.model_type)
except ValidationError as e:
raise ValidationError({'model_type': e.message})
if self.content:
attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES)
for tag in (
'span',
'p',
'div',
'img',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'colgroup',
'col',
):
attrs.setdefault(tag, set()).update({'style'})
# Allow class on structural tags used by the rich-text editor
for tag in ('div', 'span', 'img', 'table', 'td', 'th', 'col'):
attrs.setdefault(tag, set()).add('class')
# Allow image attributes used by tiptap-extension-resizable-image
attrs.setdefault('img', set()).update({'data-keep-ratio', 'colwidth'})
self.content = nh3.clean(
self.content.strip(),
attributes=attrs,
filter_style_properties={
'color',
'background-color',
'font-size',
'font-weight',
'font-style',
'font-family',
'text-decoration',
'text-align',
'border',
'border-color',
'border-style',
'border-width',
'margin',
'padding',
'column-width',
'column-height',
'min-width',
'max-width',
'min-height',
'max-height',
'width',
'height',
},
)
# nh3 does not recognise legacy IE-only CSS expression() calls as
# unsafe, so they survive style attribute filtering - strip them explicitly
self.content = re.sub(
r'expression\s*\(', '', self.content, flags=re.IGNORECASE
)
def check_save(self):
"""Check if this note can be saved."""
from InvenTree.models import InvenTreeNoteMixin
if self.template or not self.model_type:
return
# content_object is None (rather than raising) if the target row is
# missing - GenericForeignKey.__get__ catches ObjectDoesNotExist
# internally, it never propagates it.
instance = self.content_object
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
instance = self.content_object
if instance and isinstance(instance, InvenTreeNoteMixin):
instance.check_note_delete(self)
def delete(self, *args, **kwargs):
"""Perform custom delete checks before deleting a Note instance."""
self.check_delete()
super().delete(*args, **kwargs)
def cleanup_images(self):
"""Remove any images which are no longer referenced in the note content."""
for image in self.images.all():
if image.image and image.image.url not in self.content:
image.delete()
template = models.BooleanField(
default=False,
verbose_name=_('Template'),
help_text=_(
'Is this note a template (not linked to a specific model instance)?'
),
)
model_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
null=True,
blank=True,
validators=[common.validators.validate_note_model_type],
help_text=_('Target model type for this note'),
)
model_id = models.PositiveIntegerField(
null=True, blank=True, help_text=_('Target model instance ID for this note')
)
content_object = GenericForeignKey('model_type', 'model_id')
primary = models.BooleanField(
default=False,
verbose_name=_('Primary'),
help_text=_('Is this the primary note for the associated model?'),
)
title = models.CharField(
max_length=100, verbose_name=_('Title'), help_text=_('Note title')
)
description = models.CharField(
max_length=250,
blank=True,
verbose_name=_('Description'),
help_text=_('Optional description field'),
)
content = models.TextField(
blank=True,
verbose_name=_('Content'),
help_text=_('Note content'),
max_length=NOTES_MAX_LENGTH,
)
def rename_notes_image(instance, filename):
"""Function for renaming uploading image file. Will store in the 'notes' directory."""
fname = os.path.basename(filename)
return os.path.join('notes', fname)
class NotesImage(models.Model):
"""Model for storing uploading images for the 'notes' fields of various models.
Simply stores the image file, for use in the 'notes' field (of any models which support markdown).
"""
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'
)
@receiver(post_delete, sender=NotesImage, dispatch_uid='notesimage_post_delete')
def after_notesimage_deleted(sender, instance, **kwargs):
"""Remove the image file from storage once a NotesImage row is deleted.
A signal (rather than an overridden delete()) is required here: a NotesImage row is
usually removed via a cascade - e.g. deleting its parent Note, or
InvenTreeNoteMixin.delete() bulk-deleting all notes for a model instance being deleted.
Django's deletion Collector never calls a cascaded object's Python-level delete()
override, only its pre_delete/post_delete signals - regardless of whether the cascade
started from a single instance.delete() or a bulk QuerySet.delete().
"""
if instance.image:
instance.image.delete(save=False)
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
"""Model for storing barcode scans results."""
+181 -29
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,
@@ -404,7 +404,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']
@@ -825,7 +825,6 @@ class AttachmentSerializer(
def save(self, **kwargs):
"""Override the save method to handle the model_type field."""
from InvenTree.models import InvenTreeAttachmentMixin
from users.permissions import check_user_permission
model_type = self.validated_data.get('model_type', None)
@@ -839,23 +838,154 @@ class AttachmentSerializer(
model_type
)
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
raise PermissionDenied(_('Invalid model type specified for attachment'))
permission_error_msg = _(
'User does not have permission to create or edit attachments for this model'
check_model_change_permission(
user,
target_model_class,
InvenTreeAttachmentMixin,
_('Invalid model type specified for attachment'),
_(
'User does not have permission to create or edit attachments for this model'
),
)
if not check_user_permission(user, target_model_class, 'change'):
raise PermissionDenied(permission_error_msg)
# Check that the user has the required permissions to attach files to the target model
if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg)
return super().save(**kwargs)
def check_model_change_permission(
user, target_model_class, mixin_class, invalid_model_msg, permission_error_msg
):
"""Ensure a user has 'change' permission against a generic-relation target model.
Shared by any serializer whose save() must verify both that the target model
supports a given mixin (e.g. Attachment/Parameter/Note), and that the user has
'change' permission against it - the sequence of checks is identical in each
case; only the mixin class and the (separately translated, so callers keep
full-sentence translator context) error messages differ.
Raises PermissionDenied if the model class is invalid, or the user lacks
permission.
"""
from users.permissions import check_user_permission
if not target_model_class or not issubclass(target_model_class, mixin_class):
raise PermissionDenied(invalid_model_msg)
if not check_user_permission(user, target_model_class, 'change'):
raise PermissionDenied(permission_error_msg)
if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg)
def check_note_change_permission(user, *, template, model_type):
"""Check whether a user is permitted to create, edit or delete a note.
Shared between NoteSerializer.save() (create/update) and NoteDetail's
destroy handling (delete), so all three operations enforce the same rule:
staff-only for templates, model 'change' permission otherwise.
Raises PermissionDenied if the user is not permitted.
"""
if template:
if not user.is_staff:
raise PermissionDenied(
_('Only staff users can create or edit note templates')
)
return
target_model_class = model_type.model_class() if model_type else None
check_model_change_permission(
user,
target_model_class,
InvenTreeNoteMixin,
_('Invalid model type specified for note'),
_('User does not have permission to create or edit notes for this model'),
)
class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Note model."""
class Meta:
"""Meta options for NoteSerializer."""
model = common_models.Note
fields = [
'pk',
'template',
'model_type',
'model_id',
'primary',
'title',
'description',
'content',
'updated',
'updated_by',
]
read_only_fields = ['updated', 'updated_by']
def validate(self, data):
"""Validate note data — templates need no model_id; regular notes require both."""
data = super().validate(data)
is_template = data.get('template', getattr(self.instance, 'template', False))
if not is_template:
model_type = data.get('model_type') or getattr(
self.instance, 'model_type', None
)
model_id = data.get('model_id') or getattr(self.instance, 'model_id', None)
if not model_type:
raise serializers.ValidationError({
'model_type': _('This field is required.')
})
if model_id is None:
raise serializers.ValidationError({
'model_id': _('This field is required.')
})
return data
def save(self, **kwargs):
"""Save the Note instance."""
user = self.context.get('request').user
is_template = self.validated_data.get(
'template', getattr(self.instance, 'template', False)
)
model_type = self.validated_data.get('model_type') or (
self.instance and self.instance.model_type
)
check_note_change_permission(user, template=is_template, model_type=model_type)
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
@@ -923,9 +1053,6 @@ class ParameterSerializer(
def save(self, **kwargs):
"""Save the Parameter instance."""
from InvenTree.models import InvenTreeParameterMixin
from users.permissions import check_user_permission
model_type = self.validated_data.get('model_type', None)
if model_type is None and self.instance:
@@ -936,19 +1063,16 @@ class ParameterSerializer(
target_model_class = model_type.model_class()
if not issubclass(target_model_class, InvenTreeParameterMixin):
raise PermissionDenied(_('Invalid model type specified for parameter'))
permission_error_msg = _(
'User does not have permission to create or edit parameters for this model'
check_model_change_permission(
user,
target_model_class,
InvenTreeParameterMixin,
_('Invalid model type specified for parameter'),
_(
'User does not have permission to create or edit parameters for this model'
),
)
if not check_user_permission(user, target_model_class, 'change'):
raise PermissionDenied(permission_error_msg)
if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg)
instance = super().save(updated_by=user, **kwargs)
return instance
@@ -1132,3 +1256,31 @@ class TestEmailSerializer(serializers.Serializer):
fields = ['email']
email = serializers.EmailField(required=True)
class InstanceInfoSerializer(serializers.Serializer):
"""Serializer for aggregated per-instance counts (attachments, notes, parameters).
Backs a single generic lookup (see common.api.InstanceInfoView) that any
model instance's detail page can use to drive its Attachments/Notes/
Parameters tab notification dots from one request, instead of each tab
independently querying its own list endpoint just to read a count.
"""
attachment_count = serializers.IntegerField(
label=_('Attachment Count'),
help_text=_('Number of attachments associated with this instance'),
read_only=True,
)
note_count = serializers.IntegerField(
label=_('Note Count'),
help_text=_('Number of notes associated with this instance'),
read_only=True,
)
parameter_count = serializers.IntegerField(
label=_('Parameter Count'),
help_text=_('Number of parameters associated with this instance'),
read_only=True,
)
+28 -48
View File
@@ -1,10 +1,10 @@
"""Tasks (processes that get offloaded) for common app."""
import os
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError
from django.utils import timezone
@@ -15,8 +15,6 @@ 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__)
@@ -113,9 +111,16 @@ def update_news_feed():
@tracer.start_as_current_span('delete_old_notes_images')
@scheduled_task(ScheduledTask.DAILY)
def delete_old_notes_images():
"""Remove old notes images from the database.
"""Remove old, unreferenced notes images from the database.
Anything older than ~3 months is removed, unless it is linked to a note
Each NotesImage is linked to a specific Note via a required foreign key, so
(unlike the pre-refactor version of this task) we only need to check whether
the image is still referenced in *that one* note's content, rather than
searching every note-supporting model's table for a matching substring.
Anything older than ~3 months is removed, unless it is still referenced in
its associated note's content. Images whose file no longer exists in storage
are removed regardless of age, since there's nothing left to keep around.
"""
try:
from common.models import NotesImage
@@ -125,53 +130,28 @@ def delete_old_notes_images():
)
return
# Remove any notes which point to non-existent image files
for note in NotesImage.objects.all():
if not os.path.exists(note.image.path):
logger.info('Deleting note %s - image file does not exist', note.image.path)
note.delete()
# Remove any images whose file no longer exists in storage, regardless of
# age - there's nothing left to keep around
for image in NotesImage.objects.all():
if not image.image or not default_storage.exists(image.image.name):
logger.info(
'delete_old_notes_images: Deleting image %s - file does not exist',
image.pk,
)
image.delete()
note_classes = getModelsWithMixin(InvenTreeNotesMixin)
before = InvenTree.helpers.current_date() - timedelta(days=90)
for note in NotesImage.objects.filter(date__lte=before):
# Find any images which are no longer referenced by a note
old_images = NotesImage.objects.filter(date__lte=before).select_related('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))
for image in old_images:
if image.image.url not in image.note.content:
logger.info(
'delete_old_notes_images: Deleting image %s - not referenced by note %s',
image.pk,
image.note.pk,
)
image.delete()
@tracer.start_as_current_span('rebuild_parameters')
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
"""Data migration unit tests for the 'common' app."""
import importlib
import io
import os
from unittest import mock
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -10,6 +12,36 @@ from django_test_migrations.contrib.unittest_case import MigratorTestCase
from PIL import Image
def generate_note_image(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_historical_model(state, 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 = 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 get_legacy_models():
"""Return a set of legacy attachment models."""
# Legacy attachment types to convert:
@@ -290,6 +322,370 @@ class TestAttachmentThumbnailMigration(MigratorTestCase):
self.assertFalse(att.thumbnail)
class TestNoteMigrations(MigratorTestCase):
"""Test data migration of legacy 'notes' fields to the new Note model.
- Migration common.0050 copies existing 'notes' field data into the Note model,
converting the markdown content to HTML, and links any associated NotesImage objects.
- Migration common.0051 removes the legacy 'model_type' and 'model_id' fields from NotesImage.
"""
# Note: these targets must match the dependencies of the data migration (common.0050),
# to ensure that the migration plan is truncated *before* the data migration is applied
migrate_from = [
('common', '0048_notificationmessage_link'),
('build', '0059_build_tags'),
('company', '0080_company_tags'),
('order', '0121_add_line_item_discount'),
('part', '0152_alter_partpricing_currency'),
('stock', '0125_remove_mptt_fields'),
]
migrate_to = ('common', '0052_remove_notesimage_model_id_and_more')
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 = get_historical_model(self.old_state, 'common', 'NotesImage')
Part = get_historical_model(self.old_state, 'part', 'Part')
Build = get_historical_model(self.old_state, 'build', 'Build')
StockItem = get_historical_model(self.old_state, 'stock', 'StockItem')
Company = get_historical_model(self.old_state, 'company', 'Company')
ManufacturerPart = get_historical_model(
self.old_state, 'company', 'ManufacturerPart'
)
SupplierPart = get_historical_model(self.old_state, 'company', 'SupplierPart')
PurchaseOrder = get_historical_model(self.old_state, 'order', 'PurchaseOrder')
SalesOrder = get_historical_model(self.old_state, 'order', 'SalesOrder')
ReturnOrder = get_historical_model(self.old_state, 'order', 'ReturnOrder')
SalesOrderShipment = get_historical_model(
self.old_state, 'order', 'SalesOrderShipment'
)
TransferOrder = get_historical_model(self.old_state, 'order', 'TransferOrder')
# An image which is not linked to any model, but is embedded in the notes markdown
embedded_image = NotesImage.objects.create(
image=generate_note_image('embedded.png')
)
# An image which is not linked to any model, and not referenced anywhere
NotesImage.objects.create(image=generate_note_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 and no directly-linked images should not
# generate a Note entry at all
empty_notes_part = Part.objects.create(
name='Part with empty notes', description='x', notes='', **tree
)
null_notes_part = 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=generate_note_image('linked.png'), model_type='part', model_id=part.pk
)
# An image directly linked to a part whose notes field is blank - this
# must be preserved (migrated onto an empty placeholder note) rather
# than silently discarded, since it was legitimately attached
NotesImage.objects.create(
image=generate_note_image('blank_notes_linked.png'),
model_type='part',
model_id=empty_notes_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',
)
# A part whose legacy notes contain raw HTML that markdown passes through
# unchanged - this must be stripped during migration, not just the markdown
# conversion, since bulk_create() never runs Note.clean()'s sanitizer
malicious_notes_part = Part.objects.create(
name='Part with malicious notes',
description='x',
notes='Some notes\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>',
**tree,
)
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'
),
),
(
'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
self.empty_notes_part_pk = empty_notes_part.pk
self.null_notes_part_pk = null_notes_part.pk
self.malicious_notes_part_pk = malicious_notes_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, plus one empty
# placeholder note for the blank-notes part with a linked image, plus
# one note for the malicious-notes part (see test_malicious_notes_sanitized)
self.assertEqual(Note.objects.count(), len(self.expected_notes) + 2)
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)
part_content_type = ContentType.objects.get(model='part')
# The blank-notes part with a directly-linked image gets an empty
# placeholder note, so its image is preserved rather than discarded
placeholder_note = Note.objects.get(
model_type=part_content_type, model_id=self.empty_notes_part_pk
)
self.assertEqual(placeholder_note.content, '')
self.assertTrue(placeholder_note.primary)
self.assertFalse(placeholder_note.template)
# The blank-notes part with *no* linked image gets no note at all
self.assertFalse(
Note.objects.filter(
model_type=part_content_type, model_id=self.null_notes_part_pk
).exists()
)
def test_malicious_notes_sanitized(self):
"""Test that raw HTML in a legacy notes field is stripped during migration.
bulk_create() (used to migrate notes in bulk) never calls Note.save()/.clean(),
which is the only place the nh3 sanitizer normally runs - so this exercises the
migration's own sanitize_note_content() call instead.
"""
Note = self.new_state.apps.get_model('common', 'Note')
ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType')
part_content_type = ContentType.objects.get(model='part')
note = Note.objects.get(
model_type=part_content_type, model_id=self.malicious_notes_part_pk
)
self.assertNotIn('<script', note.content)
self.assertNotIn('onerror', note.content)
self.assertIn('Some notes', 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; the other three (linked to the
# main part, embedded in its content, and directly linked to the
# blank-notes part) all survive
self.assertEqual(NotesImage.objects.count(), 3)
part_content_type = ContentType.objects.get(model='part')
placeholder_note = Note.objects.get(
model_type=part_content_type, model_id=self.empty_notes_part_pk
)
blank_notes_image = NotesImage.objects.get(
image__icontains='blank_notes_linked'
)
self.assertEqual(blank_notes_image.note.pk, placeholder_note.pk)
note = Note.objects.get(model_type=part_content_type, model_id=self.part_pk)
# Both the directly linked image and the embedded image point to the
# main part's note - excluding the blank-notes part's own image, which
# points to its own placeholder note instead (checked above)
for image in NotesImage.objects.exclude(pk=blank_notes_image.pk):
self.assertEqual(image.note.pk, note.pk)
class TestNoteMigrationBatching(MigratorTestCase):
"""Test that common.0051 correctly migrates notes which span multiple batches.
Note.bulk_create() batches are only flushed once BATCH_SIZE (500 in production)
instances have accumulated, plus a final trailing flush for whatever's left over.
A bug in that boundary handling - e.g. the trailing partial batch never being
flushed, or the per-batch zip(instances, notes) misaligning across separate
bulk_create() calls - would silently drop or cross-link notes, but every instance
in TestNoteMigrations fits in a single batch, so it can't catch that. BATCH_SIZE is
patched down here so a small, fast-to-create number of instances is enough to force
multiple batches, including a non-full trailing one.
"""
migrate_from = [
('common', '0048_notificationmessage_link'),
('build', '0059_build_tags'),
('company', '0080_company_tags'),
('order', '0121_add_line_item_discount'),
('part', '0152_alter_partpricing_currency'),
('stock', '0125_remove_mptt_fields'),
]
migrate_to = ('common', '0052_remove_notesimage_model_id_and_more')
# 7 instances over batches of 3 forces two full flushes plus a trailing partial one
BATCH_SIZE = 3
N_INSTANCES = 7
def setUp(self):
"""Patch the migration's BATCH_SIZE down before it runs."""
migration_module = importlib.import_module(
'common.migrations.0051_auto_20260525_0956'
)
with mock.patch.object(migration_module, 'BATCH_SIZE', self.BATCH_SIZE):
super().setUp()
def prepare(self):
"""Create more instances of one model than fit in a single migration batch."""
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
NotesImage = get_historical_model(self.old_state, 'common', 'NotesImage')
Part = get_historical_model(self.old_state, 'part', 'Part')
self.parts = [
Part.objects.create(
name=f'Test Part {i}',
description=f'Description {i}',
notes=f'Notes for part {i}',
**tree,
)
for i in range(self.N_INSTANCES)
]
# Directly linked to the part in the middle of one batch - if a batch
# boundary ever misaligned the notes/instances pairing, this would end up
# linked to the wrong neighbour's note instead
self.linked_image = NotesImage.objects.create(
image=generate_note_image('linked.png'),
model_type='part',
model_id=self.parts[4].pk,
)
def test_all_notes_migrated_across_batches(self):
"""Every instance gets exactly one, correctly-matched note - batch boundaries aren't visible."""
Note = self.new_state.apps.get_model('common', 'Note')
NotesImage = self.new_state.apps.get_model('common', 'NotesImage')
ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType')
content_type = ContentType.objects.get(model='part')
self.assertEqual(
Note.objects.filter(model_type=content_type).count(), self.N_INSTANCES
)
for i, part in enumerate(self.parts):
note = Note.objects.get(model_type=content_type, model_id=part.pk)
self.assertIn(f'Notes for part {i}', note.content)
expected_note = Note.objects.get(
model_type=content_type, model_id=self.parts[4].pk
)
image = NotesImage.objects.get(pk=self.linked_image.pk)
self.assertEqual(image.note.pk, expected_note.pk)
def prep_currency_migration(self, vals: str):
"""Prepare the environment for the currency migration tests."""
# Set keys
+220 -19
View File
@@ -20,6 +20,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from PIL import Image
@@ -44,6 +45,7 @@ from .models import (
InvenTreeCustomUserStateModel,
InvenTreeSetting,
InvenTreeUserSetting,
Note,
NotesImage,
NotificationEntry,
NotificationMessage,
@@ -54,6 +56,7 @@ from .models import (
WebhookEndpoint,
WebhookMessage,
)
from .tasks import delete_old_notes_images
CONTENT_TYPE_JSON = 'application/json'
@@ -1722,30 +1725,228 @@ class NotesImageTest(InvenTreeAPITestCase):
# Check that no extra database entries have been created
self.assertEqual(NotesImage.objects.count(), n)
def test_valid_image(self):
"""Test upload of a valid image file."""
n = NotesImage.objects.count()
def test_image_cleanup(self):
"""Images no longer referenced in note content are deleted when the note is saved.
# Construct a simple image file
image = Image.new('RGB', (100, 100), color='red')
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)
with io.BytesIO() as output:
image.save(output, format='PNG')
contents = output.getvalue()
note = Note(
model_type=part_ct,
model_id=part.pk,
title='Image Cleanup Test Note',
content='initial',
)
note.save()
self.post(
reverse('api-notes-image-list'),
data={
'image': SimpleUploadedFile(
'test.png', contents, content_type='image/png'
)
},
format='multipart',
expected_code=201,
# 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_image_cleanup_on_cascade_delete(self):
"""Images are removed from storage when their note is deleted via a cascade.
InvenTreeNoteMixin.delete() (and Note.delete()'s own cascade to its images) delete
notes/images via Django's deletion Collector, not by calling NotesImage.delete() on
each instance directly - the collector never invokes an overridden Model.delete()
on cascaded objects, only its pre_delete/post_delete signals. This exercises that
path specifically, rather than test_image_cleanup's direct note.save()-driven cleanup.
"""
part = Part.objects.create(
name='Cascade Delete Cleanup Test Part',
description='Part for cascade-delete image-cleanup test',
active=False, # Part.delete() refuses to delete an active part
)
part_ct = ContentType.objects.get_for_model(Part)
note = Note(
model_type=part_ct, model_id=part.pk, title='Cascade Test Note', content=''
)
note.save()
img_obj = Image.new('RGB', (10, 10), color='red')
with io.BytesIO() as buf:
img_obj.save(buf, format='PNG')
png_bytes = buf.getvalue()
ni = NotesImage(note=note)
ni.image.save('cascade_cleanup.png', ContentFile(png_bytes))
image_name = ni.image.name
self.assertTrue(default_storage.exists(image_name))
# Delete the *part*, not the note or image directly - this cascades
# Part -> InvenTreeNoteMixin.delete() -> Note -> NotesImage
part.delete()
self.assertFalse(NotesImage.objects.filter(pk=ni.pk).exists())
self.assertFalse(Note.objects.filter(pk=note.pk).exists())
self.assertFalse(default_storage.exists(image_name))
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',
)
# Check that a new file has been created
self.assertEqual(NotesImage.objects.count(), n + 1)
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 DeleteOldNotesImagesTaskTest(InvenTreeAPITestCase):
"""Tests for the delete_old_notes_images scheduled task."""
def setUp(self):
"""Create a Note to attach images to."""
super().setUp()
part = Part.objects.create(name='Notes Image Task Test Part', description='x')
part_ct = ContentType.objects.get_for_model(Part)
self.note = Note.objects.create(
model_type=part_ct, model_id=part.pk, title='N', content=''
)
def _generate_image_bytes(self) -> bytes:
buf = io.BytesIO()
Image.new('RGB', (16, 16), color='blue').save(buf, format='PNG')
return buf.getvalue()
def _create_image(
self, name: str, age_days: int = 0, referenced: bool = False
) -> NotesImage:
image = NotesImage.objects.create(note=self.note)
image.image.save(name, ContentFile(self._generate_image_bytes()))
if referenced:
self.note.content = f'<img src="{image.image.url}">'
self.note.save()
if age_days:
NotesImage.objects.filter(pk=image.pk).update(
date=timezone.now() - timedelta(days=age_days)
)
return image
def test_old_unreferenced_image_is_removed(self):
"""An old image no longer referenced by its note's content is removed."""
image = self._create_image('old_unreferenced.png', age_days=100)
delete_old_notes_images()
self.assertFalse(NotesImage.objects.filter(pk=image.pk).exists())
def test_old_referenced_image_is_kept(self):
"""An old image still referenced by its note's content is kept."""
image = self._create_image('old_referenced.png', age_days=100, referenced=True)
delete_old_notes_images()
self.assertTrue(NotesImage.objects.filter(pk=image.pk).exists())
def test_recent_unreferenced_image_is_kept(self):
"""A recently-uploaded, unreferenced image is kept - not yet old enough."""
image = self._create_image('recent_unreferenced.png')
delete_old_notes_images()
self.assertTrue(NotesImage.objects.filter(pk=image.pk).exists())
def test_missing_file_is_removed_regardless_of_age(self):
"""An image whose file no longer exists in storage is removed, even if recent."""
image = self._create_image('missing_file.png')
default_storage.delete(image.image.name)
delete_old_notes_images()
self.assertFalse(NotesImage.objects.filter(pk=image.pk).exists())
class ProjectCodesTest(InvenTreeAPITestCase):
+61 -46
View File
@@ -11,52 +11,89 @@ import common.icons
from common.settings import get_global_setting
def models_with_mixin(mixin_class) -> list:
"""Return a list of models which inherit from the given mixin class."""
import InvenTree.helpers_model
return list(InvenTree.helpers_model.getModelsWithMixin(mixin_class))
def model_options_for_mixin(mixin_class) -> list:
"""Return (name, verbose_name) choices for models which inherit from the given mixin class."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in models_with_mixin(mixin_class)
]
def note_model_types():
"""Return a list of valid note model choices."""
import InvenTree.models
return models_with_mixin(InvenTree.models.InvenTreeNoteMixin)
def note_model_options():
"""Return a list of options for models which support notes."""
import InvenTree.models
return model_options_for_mixin(InvenTree.models.InvenTreeNoteMixin)
def validate_note_model_type(value):
"""Ensure that the provided content type supports notes.
Accepts either a ContentType instance, or a raw primary key - Django calls
a ForeignKey's field-level validators with the raw attname value (the
related object's pk), while callers with an actual ContentType instance
in hand (e.g. Note.clean()) can pass it directly.
"""
from django.contrib.contenttypes.models import ContentType
if not value:
return
if not isinstance(value, ContentType):
try:
value = ContentType.objects.get(pk=value)
except ContentType.DoesNotExist:
raise ValidationError(_('Invalid content type'))
if value.model_class() not in note_model_types():
raise ValidationError(_('Model type does not support notes'))
def parameter_model_types():
"""Return a list of valid parameter model choices."""
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(
InvenTree.models.InvenTreeParameterMixin
)
)
return models_with_mixin(InvenTree.models.InvenTreeParameterMixin)
def parameter_model_options():
"""Return a list of options for models which support parameters."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in parameter_model_types()
]
import InvenTree.models
return model_options_for_mixin(InvenTree.models.InvenTreeParameterMixin)
def parameter_template_model_options():
"""Return a list of options for models which support parameter templates."""
options = [
(model.__name__.lower(), model._meta.verbose_name)
for model in parameter_model_types()
]
return [(None, _('All models')), *options]
return [(None, _('All models')), *parameter_model_options()]
def attachment_model_types():
"""Return a list of valid attachment model choices."""
import InvenTree.models
return list(
InvenTree.helpers_model.getModelsWithMixin(
InvenTree.models.InvenTreeAttachmentMixin
)
)
return models_with_mixin(InvenTree.models.InvenTreeAttachmentMixin)
def attachment_model_options():
"""Return a list of options for models which support attachments."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in attachment_model_types()
]
import InvenTree.models
return model_options_for_mixin(InvenTree.models.InvenTreeAttachmentMixin)
def attachment_model_class_from_label(label: str):
@@ -93,28 +130,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:
@@ -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", "0052_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",
),
]
+3 -3
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,
@@ -490,7 +490,7 @@ class ManufacturerPart(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeMetadataModel,
):
@@ -606,8 +606,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,
):
+27 -22
View File
@@ -23,8 +23,8 @@ from InvenTree.serializers import (
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
apply_duplicate_copy_options,
)
from .models import (
@@ -111,7 +111,6 @@ class CompanySerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for Company object (full detail)."""
@@ -143,7 +142,6 @@ class CompanySerializer(
'is_customer',
'is_manufacturer',
'is_supplier',
'notes',
'parts_supplied',
'parts_manufactured',
'primary_address',
@@ -196,7 +194,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):
@@ -206,10 +206,13 @@ class CompanySerializer(
instance = super().create(validated_data)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
apply_duplicate_copy_options(
instance,
duplicate,
duplicate['original'],
copy_notes=True,
copy_parameters=True,
)
return instance
@@ -234,7 +237,6 @@ class ManufacturerPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for ManufacturerPart object."""
@@ -257,7 +259,6 @@ class ManufacturerPartSerializer(
'MPN',
'link',
'barcode_hash',
'notes',
'tags',
'parameters',
]
@@ -267,7 +268,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
@@ -278,10 +279,13 @@ class ManufacturerPartSerializer(
instance = super().create(validated_data)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
apply_duplicate_copy_options(
instance,
duplicate,
duplicate['original'],
copy_notes=True,
copy_parameters=True,
)
return instance
@@ -358,7 +362,6 @@ class SupplierPartSerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPart object."""
@@ -407,7 +410,6 @@ class SupplierPartSerializer(
'supplier',
'supplier_detail',
'updated',
'notes',
'part_detail',
'tags',
'price_breaks',
@@ -542,7 +544,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
@@ -596,10 +598,13 @@ class SupplierPartSerializer(
supplier_part.save(**kwargs)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
supplier_part.copy_parameters_from(original)
apply_duplicate_copy_options(
supplier_part,
duplicate,
duplicate['original'],
copy_notes=True,
copy_parameters=True,
)
return supplier_part
+133 -49
View File
@@ -1,7 +1,9 @@
"""Unit testing for the company app API functions."""
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from common.models import Note
from company.models import (
Address,
Company,
@@ -15,6 +17,16 @@ from part.models import Part
from users.permissions import check_user_permission
def create_note(instance, content='<p>Some notes</p>'):
"""Helper: attach a Note to a model instance for duplication tests."""
return Note.objects.create(
model_type=ContentType.objects.get_for_model(type(instance)),
model_id=instance.pk,
title='Original Note',
content=content,
)
class CompanyTest(InvenTreeAPITestCase):
"""Series of tests for the Company DRF API."""
@@ -87,6 +99,43 @@ class CompanyTest(InvenTreeAPITestCase):
response = self.get(url, data)
self.assertEqual(len(response.data), 2)
def test_company_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a Company via the API.
CompanySerializer declares its 'duplicate' options with copy_notes=True,
so notes should be copied by default (i.e. without explicitly requesting it).
"""
url = reverse('api-company-list')
create_note(self.acme)
response = self.post(
url,
{
'name': 'ACME Duplicate',
'description': 'Duplicate of ACME',
'duplicate': {'original': self.acme.pk},
},
expected_code=201,
)
duplicate = Company.objects.get(pk=response.data['pk'])
self.assertEqual(duplicate.notes.count(), 1)
self.assertEqual(duplicate.notes.first().content, '<p>Some notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'name': 'ACME Duplicate No Notes',
'description': 'Duplicate of ACME without notes',
'duplicate': {'original': self.acme.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_duplicate = Company.objects.get(pk=response.data['pk'])
self.assertEqual(no_notes_duplicate.notes.count(), 0)
def test_company_create(self):
"""Test that we can create a company via the API!"""
url = reverse('api-company-list')
@@ -161,55 +210,6 @@ class CompanyTest(InvenTreeAPITestCase):
len(self.get(url, data={'active': False}, expected_code=200).data), 1
)
def test_company_notes(self):
"""Test the markdown 'notes' field for the Company model."""
company = Company.objects.first()
assert company
pk = company.pk
url = reverse('api-company-detail', kwargs={'pk': pk})
# Attempt to inject malicious markdown into the "notes" field
xss = [
'[Click me](javascript:alert(123))',
'![x](javascript:alert(123))',
'![Uh oh...]("onerror="alert(\'XSS\'))',
]
for note in xss:
response = self.patch(url, {'notes': note}, expected_code=400)
self.assertIn(
'Data contains prohibited markdown content', str(response.data)
)
# Tests with disallowed tags
invalid_tags = [
'<iframe src="javascript:alert(123)"></iframe>',
'<canvas>A disallowed tag!</canvas>',
]
for note in invalid_tags:
response = self.patch(url, {'notes': note}, expected_code=400)
self.assertIn('Remove HTML tags from this value', str(response.data))
# The following markdown is safe, and should be accepted
good = [
'This is a **bold** statement',
'This is a *italic* statement',
'This is a [link](https://www.google.com)',
'This is an ![image](https://www.google.com/test.jpg)',
'This is a `code` block',
'This text has ~~strikethrough~~ formatting',
'This text has a raw link - https://www.google.com - and should still pass the test',
]
for note in good:
response = self.patch(url, {'notes': note}, expected_code=200)
self.assertEqual(response.data['notes'], note)
def test_company_parameters(self):
"""Test for annotation of 'parameters' field in Company API."""
url = reverse('api-company-list')
@@ -527,6 +527,48 @@ class ManufacturerTest(InvenTreeAPITestCase):
response = self.get(url, data)
self.assertEqual(len(response.data), 3)
def test_manufacturer_part_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a ManufacturerPart via the API.
ManufacturerPartSerializer declares its 'duplicate' options with
copy_notes=True, so notes should be copied by default.
"""
url = reverse('api-manufacturer-part-list')
original = ManufacturerPart.objects.first()
self.assertIsNotNone(original)
create_note(original)
response = self.post(
url,
{
'part': original.part.pk,
'manufacturer': original.manufacturer.pk,
'MPN': 'MPN_DUPLICATE',
'duplicate': {'original': original.pk},
},
expected_code=201,
)
duplicate = ManufacturerPart.objects.get(pk=response.data['pk'])
self.assertEqual(duplicate.notes.count(), 1)
self.assertEqual(duplicate.notes.first().content, '<p>Some notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'part': original.part.pk,
'manufacturer': original.manufacturer.pk,
'MPN': 'MPN_DUPLICATE_NO_NOTES',
'duplicate': {'original': original.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_duplicate = ManufacturerPart.objects.get(pk=response.data['pk'])
self.assertEqual(no_notes_duplicate.notes.count(), 0)
def test_supplier_part_create(self):
"""Test a SupplierPart can be created via the API."""
url = reverse('api-supplier-part-list')
@@ -612,6 +654,48 @@ class SupplierPartTest(InvenTreeAPITestCase):
response = self.get(url, {'part': pk}, expected_code=200)
self.assertEqual(len(response.data), n)
def test_supplier_part_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a SupplierPart via the API.
SupplierPartSerializer declares its 'duplicate' options with
copy_notes=True, so notes should be copied by default.
"""
url = reverse('api-supplier-part-list')
original = SupplierPart.objects.first()
self.assertIsNotNone(original)
create_note(original)
response = self.post(
url,
{
'part': original.part.pk,
'supplier': original.supplier.pk,
'SKU': 'SKU_DUPLICATE',
'duplicate': {'original': original.pk},
},
expected_code=201,
)
duplicate = SupplierPart.objects.get(pk=response.data['pk'])
self.assertEqual(duplicate.notes.count(), 1)
self.assertEqual(duplicate.notes.first().content, '<p>Some notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'part': original.part.pk,
'supplier': original.supplier.pk,
'SKU': 'SKU_DUPLICATE_NO_NOTES',
'duplicate': {'original': original.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_duplicate = SupplierPart.objects.get(pk=response.data['pk'])
self.assertEqual(no_notes_duplicate.notes.count(), 0)
def test_output_options(self):
"""Test the output options for SupplierPart detail."""
sp = SupplierPart.objects.all().first()
+5 -3
View File
@@ -45,7 +45,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 [
@@ -518,7 +518,9 @@ class DataImportRowConcurrencyTest(ImporterMixin, TransactionTestCase):
# the existing instance value. That's a separate, already-tracked
# issue (GH #12499) and would confound this test, which is only
# about proving the row lock closes the read/write race.
thread_a = threading.Thread(target=update, args=('notes', 'notes-a'))
thread_a = threading.Thread(
target=update, args=('link', 'https://example.com/a')
)
thread_b = threading.Thread(
target=update, args=('packaging', 'packaging-b')
)
@@ -531,7 +533,7 @@ class DataImportRowConcurrencyTest(ImporterMixin, TransactionTestCase):
self.assertEqual(errors, [])
self.item.refresh_from_db()
self.assertEqual(self.item.notes, 'notes-a')
self.assertEqual(self.item.link, 'https://example.com/a')
self.assertEqual(self.item.packaging, 'packaging-b')
@@ -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", "0121_add_line_item_discount"),
("common", "0052_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",
),
]
+25 -7
View File
@@ -319,7 +319,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,
@@ -1032,7 +1032,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
batch_code: Optional batch code for the item (optional)
expiry_date: Optional expiry date for the item (optional)
serials: Optional list of serial numbers (optional)
note: Optional notes for the item (optional)
note: Optional note for the item (optional) - recorded against the
item's RECEIVED_AGAINST_PURCHASE_ORDER tracking entry, not the
StockItem itself
"""
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
@@ -1042,9 +1044,19 @@ class PurchaseOrder(TotalPriceMixin, Order):
# List of stock items which have been created
stock_items: list[stock.models.StockItem] = []
# Per-item 'note' text, index-aligned with stock_items - StockItem no longer
# has its own 'notes' field, so this is threaded through to each item's
# RECEIVED_AGAINST_PURCHASE_ORDER tracking entry instead (see below)
stock_item_notes: list[str] = []
# List of stock items to bulk create
bulk_create_items: list[stock.models.StockItem] = []
# Notes for bulk_create_items, appended in lockstep - bulk_create_and_fetch()
# re-fetches fresh instances from the database, so any note has to be tracked
# positionally here rather than stashed on the (discarded) unsaved instance
bulk_create_notes: list[str] = []
# List of tracking entries to create
tracking_entries: list[stock.models.StockItemTracking] = []
@@ -1096,6 +1108,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
quantity = item['quantity']
barcode = item.get('barcode', '')
note = item.get('note') or item.get('notes') or ''
try:
if quantity < 0:
@@ -1170,7 +1183,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,
}
@@ -1229,9 +1241,12 @@ class PurchaseOrder(TotalPriceMixin, Order):
)
# run validation for serialized items plugin.validate_batch_code
new_item.validate_batch_code()
# run validation for serialized items plugin.validate_model_instance
new_item.run_plugin_validation()
stock_items.append(new_item)
stock_item_notes.append(note)
else:
new_item = stock.models.StockItem(**stock_data, serial='', parent=None)
@@ -1242,6 +1257,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
new_item.assign_barcode(barcode_data=barcode, save=False)
bulk_create_items.append(new_item)
bulk_create_notes.append(note)
# Bulk create new stock items
if len(bulk_create_items) > 0:
@@ -1258,9 +1274,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
)
stock_items.extend(new_items)
stock_item_notes.extend(bulk_create_notes)
# Generate a new tracking entry for each stock item
for item in stock_items:
for item, item_note in zip(stock_items, stock_item_notes, strict=True):
tracking_entries.append(
item.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
@@ -1270,6 +1287,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
'purchaseorder': self.pk,
'quantity': float(item.quantity),
},
notes=item_note,
commit=False,
)
)
@@ -1327,7 +1345,8 @@ class PurchaseOrder(TotalPriceMixin, Order):
Keyword Arguments:
batch_code: Optional batch code for the new StockItem
serials: Optional list of serial numbers to assign to the new StockItem(s)
notes: Optional notes field for the StockItem
note: Optional note, recorded against the item's tracking entry (not the
StockItem itself)
packaging: Optional packaging field for the StockItem
barcode: Optional barcode field for the StockItem
notify: If true, notify users of received items
@@ -2599,8 +2618,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,
@@ -2617,7 +2636,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
+21 -17
View File
@@ -40,8 +40,8 @@ from InvenTree.serializers import (
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin,
OptionalField,
apply_duplicate_copy_options,
)
from InvenTree.tasks import batch_offload_tasks
from order.status_codes import (
@@ -82,7 +82,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
@@ -229,7 +228,6 @@ class AbstractOrderSerializer(
'status',
'status_text',
'status_custom_key',
'notes',
'barcode_hash',
'overdue',
'duplicate',
@@ -273,8 +271,9 @@ class AbstractOrderSerializer(
line.order = instance
line.save()
if duplicate.get('copy_parameters', False):
instance.copy_parameters_from(original)
apply_duplicate_copy_options(
instance, duplicate, original, copy_notes=False, copy_parameters=False
)
return instance
@@ -382,7 +381,6 @@ class AbstractExtraLineMeta:
@register_importer()
class PurchaseOrderSerializer(
NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
@@ -427,6 +425,7 @@ class PurchaseOrderSerializer(
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@@ -1112,7 +1111,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
@register_importer()
class SalesOrderSerializer(
NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
@@ -1152,6 +1150,7 @@ class SalesOrderSerializer(
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@@ -1427,7 +1426,6 @@ class SalesOrderShipmentSerializer(
DataImportExportSerializerMixin,
FilterableSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin,
InvenTreeModelSerializer,
):
"""Serializer for the SalesOrderShipment class."""
@@ -1452,7 +1450,6 @@ class SalesOrderShipmentSerializer(
'invoice_number',
'barcode_hash',
'link',
'notes',
# Extra detail fields
'parameters',
'checked_by_detail',
@@ -1532,7 +1529,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
@@ -1543,10 +1542,13 @@ class SalesOrderShipmentSerializer(
instance = super().create(validated_data)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
instance.copy_parameters_from(original)
apply_duplicate_copy_options(
instance,
duplicate,
duplicate['original'],
copy_notes=True,
copy_parameters=True,
)
return instance
@@ -2141,7 +2143,6 @@ class SalesOrderExtraLineSerializer(
@register_importer()
class ReturnOrderSerializer(
NotesFieldMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
TotalPriceMixin,
@@ -2176,6 +2177,7 @@ class ReturnOrderSerializer(
order.models.ReturnOrder.objects.all(),
copy_extra_lines=True,
copy_parameters=True,
copy_notes=True,
)
@staticmethod
@@ -2438,7 +2440,6 @@ class ReturnOrderExtraLineSerializer(
@register_importer()
class TransferOrderSerializer(
NotesFieldMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeModelSerializer,
@@ -2468,7 +2469,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
+212
View File
@@ -632,6 +632,58 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po_dup.extra_lines.count(), po.extra_lines.count())
self.assertEqual(po_dup.lines.count(), 0)
def test_po_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a PurchaseOrder via the API.
PurchaseOrderSerializer declares its 'duplicate' options with
copy_notes=True, so notes should be copied by default (i.e. without
explicitly requesting it).
"""
from common.models import Note
self.assignRole('purchase_order.add')
po = models.PurchaseOrder.objects.get(pk=1)
Note.objects.create(
model_type=ContentType.objects.get_for_model(models.PurchaseOrder),
model_id=po.pk,
title='Original Note',
content='<p>Some purchase order notes</p>',
)
response = self.post(
reverse('api-po-list'),
{
'supplier': po.supplier.pk,
'reference': 'PO-9997',
'description': po.description,
'duplicate': {'original': po.pk},
},
expected_code=201,
)
po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk'])
self.assertEqual(po_dup.notes.count(), 1)
self.assertEqual(
po_dup.notes.first().content, '<p>Some purchase order notes</p>'
)
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
reverse('api-po-list'),
{
'supplier': po.supplier.pk,
'reference': 'PO-9996',
'description': po.description,
'duplicate': {'original': po.pk, 'copy_notes': False},
},
expected_code=201,
)
po_no_notes = models.PurchaseOrder.objects.get(pk=response.data['pk'])
self.assertEqual(po_no_notes.notes.count(), 0)
def test_po_cancel(self):
"""Test the PurchaseOrderCancel API endpoint."""
po = models.PurchaseOrder.objects.get(pk=1)
@@ -1605,6 +1657,66 @@ class PurchaseOrderReceiveTest(OrderTest):
line.refresh_from_db()
self.assertEqual(line.received, line.quantity)
def test_receive_note_recorded_on_tracking_entry(self):
"""Test that a per-item 'note' is recorded on the tracking entry, not the StockItem.
StockItem no longer has its own 'notes' field - the note supplied when
receiving an item is expected to land on that item's
RECEIVED_AGAINST_PURCHASE_ORDER tracking entry instead.
"""
response = self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 50,
'note': 'Damaged box, 2 units short',
}
],
'location': 1,
},
expected_code=201,
).data
stock_item = StockItem.objects.get(pk=response[0]['pk'])
self.assertEqual(stock_item.tracking_info.count(), 1)
entry = stock_item.tracking_info.first()
self.assertEqual(
entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
)
self.assertEqual(entry.notes, 'Damaged box, 2 units short')
def test_receive_note_recorded_on_tracking_entry_serialized(self):
"""Test that a per-item 'note' reaches the tracking entry for serialized items too.
Serialized items are created via a different code path to non-serialized
ones (StockItem._create_serial_numbers(), rather than a bulk_create()), so
this is tested separately.
"""
self.post(
self.url,
{
'items': [
{
'line_item': 1,
'quantity': 3,
'serial_numbers': '200+',
'note': 'Received via serialized batch',
}
],
'location': 1,
},
expected_code=201,
)
for i in range(200, 203):
item = StockItem.objects.get(serial_int=i)
self.assertEqual(item.tracking_info.count(), 1)
entry = item.tracking_info.first()
self.assertEqual(entry.notes, 'Received via serialized batch')
def test_bulk_receive_query_benchmark(self):
"""Benchmark: measure the number of DB queries required to receive 100 line items at once."""
InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None)
@@ -2036,6 +2148,58 @@ class SalesOrderTest(OrderTest):
self.assertEqual(duplicate_so.customer, so.customer)
self.assertEqual(duplicate_so.parameters.count(), 5)
def test_so_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a SalesOrder via the API.
SalesOrderSerializer declares its 'duplicate' options with
copy_notes=True, so notes should be copied by default (i.e. without
explicitly requesting it).
"""
from common.models import Note
url = reverse('api-so-list')
self.assignRole('sales_order.add')
so = models.SalesOrder.objects.get(pk=1)
Note.objects.create(
model_type=ContentType.objects.get_for_model(models.SalesOrder),
model_id=so.pk,
title='Original Note',
content='<p>Some sales order notes</p>',
)
response = self.post(
url,
{
'reference': 'SO-12347',
'customer': so.customer.pk,
'duplicate': {'original': so.pk},
},
expected_code=201,
)
duplicate_so = models.SalesOrder.objects.get(pk=response.data['pk'])
self.assertEqual(duplicate_so.notes.count(), 1)
self.assertEqual(
duplicate_so.notes.first().content, '<p>Some sales order notes</p>'
)
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'reference': 'SO-12348',
'customer': so.customer.pk,
'duplicate': {'original': so.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_so = models.SalesOrder.objects.get(pk=response.data['pk'])
self.assertEqual(no_notes_so.notes.count(), 0)
def test_so_cancel(self):
"""Test API endpoint for cancelling a SalesOrder."""
so = models.SalesOrder.objects.get(pk=1)
@@ -2906,6 +3070,54 @@ class SalesOrderAllocateTest(OrderTest):
len(response.data), count_before + 3 * models.SalesOrder.objects.count()
)
def test_shipment_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a SalesOrderShipment via the API.
SalesOrderShipmentSerializer declares its 'duplicate' options with
copy_notes=True, so notes should be copied by default (i.e. without
explicitly requesting it).
"""
from common.models import Note
url = reverse('api-so-shipment-list')
Note.objects.create(
model_type=ContentType.objects.get_for_model(models.SalesOrderShipment),
model_id=self.shipment.pk,
title='Original Note',
content='<p>Some shipment notes</p>',
)
response = self.post(
url,
{
'order': self.order.pk,
'reference': 'SH-DUP',
'duplicate': {'original': self.shipment.pk},
},
expected_code=201,
)
duplicate = models.SalesOrderShipment.objects.get(pk=response.data['pk'])
self.assertEqual(duplicate.notes.count(), 1)
self.assertEqual(duplicate.notes.first().content, '<p>Some shipment notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
url,
{
'order': self.order.pk,
'reference': 'SH-DUP-NO-NOTES',
'duplicate': {'original': self.shipment.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_duplicate = models.SalesOrderShipment.objects.get(
pk=response.data['pk']
)
self.assertEqual(no_notes_duplicate.notes.count(), 0)
def test_output_options(self):
"""Test the various output options for the SalesOrderAllocation detail endpoint."""
self.run_output_test(
@@ -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
@@ -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", "0153_bomitem_piece_count_bomitem_piece_size"),
("common", "0052_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="part",
name="notes",
),
]
+1 -1
View File
@@ -467,8 +467,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,
+3 -8
View File
@@ -535,7 +535,6 @@ class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class PartSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
):
@@ -577,7 +576,6 @@ class PartSerializer(
'minimum_stock',
'maximum_stock',
'name',
'notes',
'parameters',
'pk',
'purchaseable',
@@ -1053,17 +1051,14 @@ class PartSerializer(
if duplicate.get('copy_bom', False):
instance.copy_bom_from(original)
if duplicate.get('copy_notes', False):
instance.notes = original.notes
instance.save()
InvenTree.serializers.apply_duplicate_copy_options(
instance, duplicate, original, copy_notes=False, copy_parameters=False
)
if duplicate.get('copy_image', False):
instance.image = original.image
instance.save()
if duplicate.get('copy_parameters', False):
instance.copy_parameters_from(original)
if duplicate.get('copy_tests', False):
instance.copy_tests_from(original)
+44 -64
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, override_settings
@@ -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 common.settings import set_global_setting
from company.models import Company, SupplierPart
from InvenTree.config import get_testfolder_dir
@@ -1436,23 +1437,6 @@ class PartAPITest(PartAPITestBase):
date = datetime.fromisoformat(item['creation_date'])
self.assertGreaterEqual(date, date_compare)
def test_part_notes(self):
"""Test the 'notes' field."""
# First test the 'LIST' endpoint - no notes information provided
url = reverse('api-part-list')
response = self.get(url, {'limit': 1}, expected_code=200)
data = response.data['results'][0]
self.assertNotIn('notes', data)
# Second, test the 'DETAIL' endpoint - notes information provided
url = reverse('api-part-detail', kwargs={'pk': data['pk']})
response = self.get(url, expected_code=200)
self.assertIn('notes', response.data)
def test_output_options(self):
"""Test the output options for PartList list."""
self.run_output_test(
@@ -1668,40 +1652,6 @@ class PartCreationTests(PartAPITestBase):
response = self.post(url, data, expected_code=400)
self.assertIn('non_field_errors', response.data)
def test_notes_on_create(self):
"""Test that notes can be set when creating a Part."""
list_url = reverse('api-part-list')
notes = """
### Created from importer
Notes should persist during part creation.
"""
expected_notes = notes.strip()
response = self.post(
list_url,
{
'name': 'part with notes',
'description': 'Part notes are created in the same request',
'category': 1,
'notes': notes,
},
expected_code=201,
)
self.assertEqual(response.data['notes'], expected_notes)
part = Part.objects.get(pk=response.data['pk'])
self.assertEqual(part.notes, expected_notes)
detail_url = reverse('api-part-detail', kwargs={'pk': part.pk})
response = self.get(detail_url, expected_code=200)
self.assertEqual(response.data['notes'], expected_notes)
response = self.get(list_url, {'limit': 1}, expected_code=200)
self.assertNotIn('notes', response.data['results'][0])
def test_initial_stock(self):
"""Tests for initial stock quantity creation."""
@@ -1846,6 +1796,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'),
@@ -1871,7 +1829,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)
@@ -2441,20 +2399,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
@@ -2467,13 +2438,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):
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
@@ -171,8 +170,9 @@ content: "v{{ report_revision }} - {% format_date date %}";
<h3>{% trans "Notes" %}</h3>
{% if build.notes %}
{{ build.notes|markdownify }}
{% note build as build_notes_content %}
{% if build_notes_content %}
{{ build_notes_content }}
{% endif %}
{% endblock page_content %}
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
@@ -115,7 +115,7 @@ table td.expand {
</td>
<td>{{ line.part.IPN }}</td>
<td>{% decimal line.quantity %}</td>
<td>{{ line.notes }}</td>
<td>{% note line %}</td>
</tr>
{% endfor %}
</tbody>
@@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
@@ -21,6 +21,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
@@ -503,6 +504,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
+73 -2
View File
@@ -9,17 +9,20 @@ from unittest.mock import patch
from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.template.loader import render_to_string
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from pypdf import PdfReader
import report.models as report_models
from build.models import Build
from common.models import Attachment
from common.models import Attachment, Note
from common.settings import set_global_setting
from InvenTree.config import get_base_dir
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
@@ -27,7 +30,7 @@ from order.models import PurchaseOrder, ReturnOrder, SalesOrder
from part.models import Part
from plugin.registry import registry
from report.models import LabelTemplate, ReportTemplate
from stock.models import StockItem
from stock.models import StockItem, StockLocation
class ReportTest(InvenTreeAPITestCase):
@@ -309,6 +312,74 @@ class ReportTest(InvenTreeAPITestCase):
self.assertIsNotNone(output.output)
self.assertTrue(output.output.name.endswith('.pdf'))
def test_print_build_order(self):
"""Test that the built-in Build Order report renders correctly.
Regression test: this report renders a build's notes via the '{% note %}'
tag - Build.notes is now a QuerySet (via InvenTreeNoteMixin), not text, so
the old '{{ build.notes|markdownify }}' would error out during rendering.
"""
template = ReportTemplate.objects.filter(
enabled=True, model_type='build'
).first()
assert template
build = Build.objects.first()
assert build
Note.objects.create(
model_type=ContentType.objects.get_for_model(Build),
model_id=build.pk,
title='Build Note',
content='<p>Handle with <strong>care</strong></p>',
)
output = template.print([build])
self.assertTrue(output.complete)
self.assertIsNotNone(output.output)
self.assertTrue(output.output.name.endswith('.pdf'))
def test_print_stock_location(self):
"""Test that the built-in Stock Location report renders each item's note.
Regression test: this report renders each contained StockItem's note
inline via the '{% note %}' tag - StockItem.notes is now a QuerySet
(via InvenTreeNoteMixin), not text, so the old '{{ line.notes }}' would
render a broken QuerySet repr instead of note content.
Renders the template directly (rather than going through
ReportTemplate.print(), as test_print_build_order does) because
StockLocation.report_context() unconditionally generates a barcode,
which depends on a barcode plugin being registered - unrelated to what
this test is actually checking, and not reliably available in every
test environment.
"""
location = StockLocation.objects.create(name='Note Report Test Location')
item = StockItem.objects.create(
part=Part.objects.first(), quantity=5, location=location
)
Note.objects.create(
model_type=ContentType.objects.get_for_model(StockItem),
model_id=item.pk,
title='Item Note',
content='<p>Fragile <strong>handle with care</strong></p>',
)
html = render_to_string(
'report/inventree_stock_location_report.html',
{
'stock_location': location,
'stock_items': StockItem.objects.filter(location=location),
'report_revision': 1,
'date': now(),
},
)
self.assertIn('Fragile', html)
self.assertIn('<strong>handle with care</strong>', html)
def test_print_custom_template(self):
"""Create a new template, print it, and check the output."""
template_string = """
+26
View File
@@ -54,6 +54,7 @@ from InvenTree.mixins import (
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
)
from InvenTree.serializers import apply_duplicate_copy_options
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder
from order.serializers import (
PurchaseOrderSerializer,
@@ -1260,9 +1261,30 @@ 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']
# copy_history/copy_tests don't follow the copy_<x>_from() naming
# convention (copyHistoryFrom/copyTestResultsFrom), so still need
# handling here - only copy_notes can go through the shared helper
apply_duplicate_copy_options(item, duplicate, original, copy_notes=True)
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
@@ -1278,6 +1300,8 @@ class StockList(
item.set_status(status_value)
item.save()
apply_duplicate_options(item)
if entry := item.add_tracking_entry(
StockHistoryCode.CREATED,
user,
@@ -1310,6 +1334,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()
@@ -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", "0127_alter_stockitemtestresult_options"),
("common", "0052_remove_notesimage_model_id_and_more")
]
operations = [
migrations.RemoveField(
model_name="stockitem",
name="notes",
),
]
+1 -1
View File
@@ -426,7 +426,7 @@ STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST
class StockItem(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeNoteMixin,
InvenTree.models.InvenTreeTagsMixin,
StatusCodeMixin,
report.mixins.InvenTreeReportMixin,
+25 -1
View File
@@ -328,6 +328,8 @@ class StockItemSerializer(
export_exclude_fields = ['tags', 'tracking_items']
SKIP_CREATE_FIELDS = ['duplicate']
export_child_fields = [
'part_detail.name',
'part_detail.description',
@@ -363,7 +365,6 @@ class StockItemSerializer(
'in_stock',
'is_building',
'link',
'notes',
'owner',
'packaging',
'parent',
@@ -385,6 +386,7 @@ class StockItemSerializer(
'purchase_price_currency',
'use_pack_size',
'serial_numbers',
'duplicate',
# Annotated fields
'allocated',
'expired',
@@ -472,6 +474,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:
+45 -1
View File
@@ -17,7 +17,7 @@ import build.models
import company.models
import order.models
import part.models
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting, Note
from common.settings import set_global_setting
from InvenTree.unit_test import (
InvenTreeAPIPerformanceTestCase,
@@ -1634,6 +1634,50 @@ class StockItemTest(StockAPITestCase):
self.assertEqual(response.data[0]['location'], None)
def test_duplicate_copies_notes(self):
"""Test that notes are copied when duplicating a StockItem via the API.
StockItemSerializer declares its 'duplicate' options with copy_notes=True,
so notes should be copied by default (i.e. without explicitly requesting it).
"""
part = Part.objects.create(name='Duplicate Notes Part', description='x')
original = StockItem.objects.create(part=part, quantity=10)
Note.objects.create(
model_type=ContentType.objects.get_for_model(StockItem),
model_id=original.pk,
title='Original Note',
content='<p>Some stock item notes</p>',
)
response = self.post(
self.list_url,
data={
'part': part.pk,
'quantity': 5,
'duplicate': {'original': original.pk},
},
expected_code=201,
)
new_item = StockItem.objects.get(pk=response.data[0]['pk'])
self.assertEqual(new_item.notes.count(), 1)
self.assertEqual(new_item.notes.first().content, '<p>Some stock item notes</p>')
# Explicitly disabling copy_notes must not copy any notes
response = self.post(
self.list_url,
data={
'part': part.pk,
'quantity': 5,
'duplicate': {'original': original.pk, 'copy_notes': False},
},
expected_code=201,
)
no_notes_item = StockItem.objects.get(pk=response.data[0]['pk'])
self.assertEqual(no_notes_item.notes.count(), 0)
def test_stock_item_create(self):
"""Test creation of a StockItem via the API."""
# POST with an empty part reference
+1
View File
@@ -206,6 +206,7 @@ def get_ruleset_ignore() -> list[str]:
'common_inventreeusersetting',
'common_notificationentry',
'common_notificationmessage',
'common_note',
'common_notesimage',
'common_projectcode',
'common_webhookendpoint',
+10 -1
View File
@@ -61,7 +61,16 @@ class TestBackfillUserProfiles(MigratorTestCase):
class MFAMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the users app."""
migrate_from = ('users', '0012_alter_ruleset_can_view')
# NOTE: otp_totp / otp_static are pinned explicitly (not just relying on
# 'users' 0012) because the merged cross-app migration plan that
# django_test_migrations truncates against is only incidentally ordered -
# unrelated migrations elsewhere in the project can shift whether these
# third-party app migrations land before or after 'users' 0012.
migrate_from = [
('users', '0012_alter_ruleset_can_view'),
('otp_totp', '0002_auto_20190420_0723'),
('otp_static', '0002_throttling'),
]
migrate_to = ('users', '0013_migrate_mfa_20240408_1659')
def prepare(self):
+3 -1
View File
@@ -252,10 +252,12 @@ export enum ApiEndpoints {
// Miscellaneous API endpoints
attachment_list = 'attachment/',
instance_info = 'instance-info/',
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/',
scim_config = 'admin/scim/',
@@ -368,5 +368,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'
}
};
+2 -1
View File
@@ -39,7 +39,8 @@ export enum ModelType {
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error',
tag = 'tag'
tag = 'tag',
notetemplate = 'notetemplate'
}
export enum PluginPanelKey {
+14 -2
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"
},
@@ -141,7 +150,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",
@@ -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;
}
@@ -1,239 +1,760 @@
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 { useQuery, useQueryClient } 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 { formatDate } from '../../defaults/formatters';
import { useNoteFields, useNoteTemplateFields } from '../../forms/CommonForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
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-end'>
<HoverCard.Target>
<ActionIcon variant='transparent'>
<IconInfoCircle />
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<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>
</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 queryClient = useQueryClient();
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]);
// Callback to save notes to the server
const saveNotes = useCallback(
(markdown: string) => {
if (!noteUrl) {
return;
}
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'
});
});
},
[api, noteUrl]
const notesQueryKey = useMemo(
() => ['notes', modelType, modelId, templateMode],
[modelType, modelId, templateMode]
);
const editorOptions: SimpleMde.Options = useMemo(() => {
const icons: any[] = [];
// Fetch the available notes for the given model type and ID (or all templates)
const notesQuery = useQuery({
queryKey: notesQueryKey,
queryFn: async () => {
const params: Record<string, any> = templateMode
? { template: true }
: { model_id: modelId, model_type: modelType };
if (editing) {
icons.push({
name: 'save-notes',
action: (editor: SimpleMde) => {
saveNotes(editor.value());
},
className: 'fa fa-save',
title: t`Save Notes`
});
return api
.get(apiUrl(ApiEndpoints.note_list), { params })
.then((response) => response.data ?? []);
},
staleTime: 0,
refetchOnWindowFocus: false,
refetchOnMount: true,
enabled: templateMode ? true : !!modelId && !!modelType
});
icons.push('|');
const [selectedNote, setSelectedNote] = useState<any>(undefined);
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
// Push a note's content into the editor, discarding any local unsaved edits.
// Used both for switching to a different note, and for the explicit "reset"
// action, which intentionally discards unsaved changes.
const applyNoteContent = useCallback(
(note: any) => {
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 }
);
}
icons.push('|', 'undo', 'redo'); // Undo/Redo
setIsDirty(false);
},
[editor]
);
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);
// Track which note's content is currently loaded into the editor. A
// background refetch of the notes list (e.g. after saving, editing a note's
// metadata elsewhere, or another tab changing notes) must not silently
// overwrite content the user is actively editing - only (re)load editor
// content when the selected note itself changes, or when there are no
// unsaved local edits to protect. Header metadata (title/description/etc.)
// stays in sync regardless, since only the editor content is at risk.
const loadedNoteIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (mdeInstance) {
const previewMode = !(editable && editing);
const noteId = selectedNoteId ?? -1;
const note = notesQuery.data?.find((note: any) => note.pk === noteId);
mdeInstance.codemirror?.setOption('readOnly', previewMode);
setSelectedNote(note);
// Ensure the preview mode is toggled if required
if (mdeInstance.isPreviewActive() != previewMode) {
const sibling =
mdeInstance?.codemirror.getWrapperElement()?.nextSibling;
if (sibling != null && editable != false) {
EasyMDE.togglePreview(mdeInstance);
}
}
const switchingNote = loadedNoteIdRef.current !== noteId;
if (switchingNote || !isDirty) {
loadedNoteIdRef.current = noteId;
applyNoteContent(note);
}
}, [mdeInstance, editable, editing]);
}, [editor, selectedNoteId, notesQuery.data, isDirty, applyNoteContent]);
// Adjust the note selection
useEffect(() => {
if (!notesQuery.data) return;
const stillExists =
selectedNoteId &&
notesQuery.data.some((note: any) => note.pk === selectedNoteId);
if (stillExists) return;
const paramSlug = searchParams.get('note');
const fromParam =
paramSlug &&
notesQuery.data.find(
(note: any) => identifierString(note.title ?? '') === paramSlug
);
if (fromParam) {
setSelectedNoteId(fromParam.pk);
return;
}
const primary = notesQuery.data.find((note: any) => note.primary);
setSelectedNoteId((primary ?? notesQuery.data[0])?.pk ?? undefined);
}, [notesQuery.data]);
// Templates are staff-only; regular notes follow the linked model's permissions
const hasNotePermission = useCallback(
(action: 'change' | 'delete'): boolean => {
if (templateMode) {
return user.isStaff();
}
if (!modelType) {
return false;
}
return action === 'change'
? user.hasChangePermission(modelType)
: user.hasDeletePermission(modelType);
},
[user, modelType, templateMode]
);
const canEdit: boolean = useMemo(
() =>
hasNotePermission('change') &&
notesQuery.isFetched &&
notesQuery.isSuccess &&
!!notesQuery.data,
[hasNotePermission, 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: () => {
// Deleting the currently-open note can leave no note selected (if it was
// the last one) - exit edit mode too, so the UI doesn't get stranded
// with 'isEditing' stuck true and nothing left to edit/select.
setIsEditing(false);
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(() => {
const note = notesQuery.data?.find(
(note: any) => note.pk === (selectedNoteId ?? -1)
);
applyNoteContent(note);
}, [selectedNoteId, notesQuery.data, applyNoteContent]);
const saveNote = useCallback(() => {
// Guard against the global mod+s hotkey firing while there's nothing to
// save - e.g. the user isn't currently editing this note, or lacks
// permission to (in which case the editor was never made editable, so
// there's nothing dirty to persist anyway).
if (!canEdit || !isEditing || !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((response) => {
// Merge the updated note directly into the cached notes list, rather
// than refetching - a refetch is async, so the content-sync effect
// (keyed on isDirty) can run against the *old* cached data in the gap
// between setIsDirty(false) below and the refetch resolving, visibly
// reverting the editor to the pre-save content until it lands.
queryClient.setQueryData(notesQueryKey, (previous: any[] | undefined) =>
previous?.map((note: any) =>
note.pk === selectedNoteId ? (response.data ?? note) : note
)
);
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
});
});
}, [
canEdit,
isEditing,
selectedNoteId,
editor,
queryClient,
notesQueryKey,
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'>
<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`} position='top-end'>
<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`} position='top-end'>
<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`}
position='top-end'
>
<ActionIcon
aria-label='reset-note'
variant='transparent'
onClick={reloadNote}
disabled={!canEdit || !isDirty}
>
<IconReload />
</ActionIcon>
</Tooltip>
)}
{isEditing && !isDirty && (
<Tooltip label={t`Finish editing`} position='top-end'>
<ActionIcon
aria-label='finish-editing-note'
variant='transparent'
onClick={() => setIsEditing(false)}
color='green'
>
<IconCheck />
</ActionIcon>
</Tooltip>
)}
<NoteInfoHover note={selectedNote} />
<OptionsActionDropdown
tooltip={t`Note Actions`}
tooltipPosition='top-end'
actions={[
EditItemAction({
hidden: !selectedNote || !canEdit,
onClick: () => {
editNote.open();
}
}),
DeleteItemAction({
hidden:
!selectedNote ||
isEditing ||
!hasNotePermission('delete'),
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>
</>
);
}
@@ -134,16 +134,19 @@ export function ActionDropdown({
export function OptionsActionDropdown({
actions = [],
tooltip = t`Options`,
tooltipPosition = 'bottom',
hidden = false
}: Readonly<{
actions: ActionDropdownItem[];
tooltip?: string;
tooltipPosition?: FloatingPosition;
hidden?: boolean;
}>) {
return (
<ActionDropdown
icon={<IconDotsVertical />}
tooltip={tooltip}
tooltipPosition={tooltipPosition}
actions={actions}
hidden={hidden}
noindicator
@@ -3,38 +3,24 @@ import { Skeleton } from '@mantine/core';
import { IconPaperclip } from '@tabler/icons-react';
import type { ModelType } from '@lib/enums/ModelType';
import { ApiEndpoints, apiUrl } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import { api } from '../../App';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
export default function AttachmentPanel({
model_type,
model_id
model_id,
attachment_count
}: {
model_type: ModelType;
model_id: number | undefined;
attachment_count?: number;
}): PanelType {
return {
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
hotkey: 'mod+Shift+A',
notification_dot: async () => {
if (!model_type || !model_id) {
return null;
}
return api
.get(apiUrl(ApiEndpoints.attachment_list), {
params: {
model_type: model_type,
model_id: model_id,
limit: 1
}
})
.then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null));
},
notification_dot: attachment_count ? 'info' : null,
content:
model_type && model_id ? (
<AttachmentTable model_type={model_type} model_id={model_id} />
@@ -4,37 +4,30 @@ 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,
model_id,
editable,
has_note
note_count
}: {
model_type: ModelType;
model_id: number | undefined;
editable?: boolean;
has_note?: boolean;
note_count?: number;
}): PanelType {
const user = useUserState.getState();
return {
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
hotkey: 'mod+Shift+N',
notification_dot: has_note ? 'info' : null,
notification_dot: note_count ? '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 />
),
@@ -1,5 +1,6 @@
import {
ActionIcon,
Alert,
Box,
Divider,
Group,
@@ -10,10 +11,12 @@ import {
Stack,
Tabs,
Text,
Title,
Tooltip,
UnstyledButton
} from '@mantine/core';
import {
IconExclamationCircle,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse
} from '@tabler/icons-react';
@@ -51,6 +54,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';
@@ -105,26 +109,34 @@ function PanelTabComponent({
const visibility = useDocumentVisibility();
const location = useLocation();
// Check if we should display an indicator dot for this panel
const isDynamicDot = typeof panel.notification_dot === 'function';
// Check if we should display an indicator dot for this panel.
// Only self-fetching (function) dots go through react-query, as they need
// caching around their own async call. Static dot values are derived from
// props the caller already re-renders on, so they are read directly below -
// routing them through a query keyed only on panel.name would freeze the
// first-seen value (e.g. `null` before data loads) and never update.
const notificationDot = useQuery({
enabled: panel.notification_dot !== undefined && visibility === 'visible',
enabled: isDynamicDot && visibility === 'visible',
queryKey: ['panel-notification', panel.name],
queryFn: async () => {
if (panel.notification_dot === undefined) {
return null;
} else if (typeof panel.notification_dot === 'function') {
if (typeof panel.notification_dot === 'function') {
return await panel.notification_dot();
} else {
return panel.notification_dot as PanelIndicatorType;
}
return null;
},
staleTime: 5 * 60 * 1000, // cache for 5 minutes
refetchOnMount: false,
refetchOnWindowFocus: false
});
const indicatorValue: PanelIndicatorType | undefined = isDynamicDot
? notificationDot.data
: (panel.notification_dot as PanelIndicatorType | undefined);
const indicatorColor: MantineColor | undefined = useMemo(() => {
switch (notificationDot.data) {
switch (indicatorValue) {
case 'info':
return 'blue';
case 'warning':
@@ -134,7 +146,7 @@ function PanelTabComponent({
default:
return undefined;
}
}, [notificationDot.data]);
}, [indicatorValue]);
return (
<Tooltip
@@ -291,21 +303,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}${location.search}`;
navigateToLink(url, navigate, event);
@@ -315,15 +321,43 @@ 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 />
<Alert
color='red'
icon={<IconExclamationCircle />}
p='sm'
>{t`You have unsaved changes. Are you sure you want to leave this panel?`}</Alert>
</>
),
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
@@ -358,13 +392,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}`}>
@@ -1,23 +1,22 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { Skeleton } from '@mantine/core';
import { IconListDetails } from '@tabler/icons-react';
import { api } from '../../App';
import { ParameterTable } from '../../tables/general/ParameterTable';
export default function ParametersPanel({
model_type,
model_id,
hidden,
allowEdit = true
allowEdit = true,
parameter_count
}: {
model_type: ModelType;
model_id: number | undefined;
hidden?: boolean;
allowEdit?: boolean;
parameter_count?: number;
}): PanelType {
return {
name: 'parameters',
@@ -25,21 +24,7 @@ export default function ParametersPanel({
icon: <IconListDetails />,
hotkey: 'mod+Shift+P',
hidden: hidden ?? false,
notification_dot: async () => {
if (!model_type || !model_id) {
return null;
}
return api
.get(apiUrl(ApiEndpoints.parameter_list), {
params: {
model_type: model_type,
model_id: model_id,
limit: 1
}
})
.then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null));
},
notification_dot: parameter_count ? 'info' : null,
content:
model_type && model_id ? (
<ParameterTable
@@ -19,6 +19,7 @@ import {
RenderSelectionList,
RenderTag
} from './Generic';
import { RenderNoteTemplate } from './Note';
import {
RenderPurchaseOrder,
RenderReturnOrder,
@@ -48,6 +49,7 @@ registerModelRenderers({
[ModelType.parameter]: RenderParameter,
[ModelType.parametertemplate]: RenderParameterTemplate,
[ModelType.manufacturerpart]: RenderManufacturerPart,
[ModelType.notetemplate]: RenderNoteTemplate,
[ModelType.owner]: RenderOwner,
[ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory,
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
export function RenderNoteTemplate({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
instance && (
<RenderInlineModel
primary={instance.title}
suffix={instance.description}
/>
)
);
}
+2 -1
View File
@@ -139,7 +139,8 @@ export function useBuildOrderFields({
duplicate: DuplicateField({
originalId: duplicateBuildId,
extraFields: {
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
})
};
+89
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
@@ -305,6 +306,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: {},
+6 -3
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: {}
}
})
};
@@ -325,7 +325,8 @@ export function usePurchaseOrderFields({
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
+2 -1
View File
@@ -89,7 +89,8 @@ export function useReturnOrderFields({
value: duplicateOrderId
},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
+2 -1
View File
@@ -103,7 +103,8 @@ export function useSalesOrderFields({
},
copy_lines: {},
copy_extra_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
+24 -2
View File
@@ -34,6 +34,7 @@ import {
IconChevronDown,
IconChevronUp,
IconCoins,
IconCopy,
IconCurrencyDollar,
IconLink,
IconPackage,
@@ -84,7 +85,7 @@ import {
} from '../hooks/UseGenerator';
import useStatusCodes from '../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
import { DuplicateField, TagsField } from './CommonFields';
/**
* Construct a set of fields for creating / editing a StockItem instance
@@ -96,7 +97,8 @@ export function useStockFields({
create = false,
supplierPartId,
pricing,
modalId
modalId,
duplicateStockItem
}: {
partId?: number;
locationId?: number;
@@ -105,6 +107,7 @@ export function useStockFields({
create: boolean;
supplierPartId?: number;
pricing?: { [priceBreak: number]: [number, string] };
duplicateStockItem?: any;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
@@ -331,6 +334,24 @@ export function useStockFields({
delete fields.serial_numbers;
}
// Additional fields for stock item duplication
if (create && duplicateStockItem?.pk) {
fields.duplicate = {
icon: <IconCopy />,
...DuplicateField({
originalId: duplicateStockItem.pk,
extraFields: {
copy_notes: { value: true },
copy_history: { value: false },
copy_tests: {
value: false,
hidden: !duplicateStockItem?.part_detail?.testable
}
}
})
};
}
return fields;
}, [
stockItem,
@@ -346,6 +367,7 @@ export function useStockFields({
purchasePriceCurrency,
serialGenerator.result,
batchGenerator.result,
duplicateStockItem,
create
]);
}
@@ -59,7 +59,8 @@ export function useTransferOrderFields({
value: duplicateOrderId
},
copy_lines: {},
copy_parameters: {}
copy_parameters: {},
copy_notes: {}
}
};
}
@@ -0,0 +1,56 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../contexts/ApiContext';
export interface InstanceInfo {
attachment_count: number;
note_count: number;
parameter_count: number;
}
const emptyInstanceInfo: InstanceInfo = {
attachment_count: 0,
note_count: 0,
parameter_count: 0
};
/**
* Fetch aggregated attachment/note/parameter counts for a single model instance.
*
* A single generic lookup which detail pages can use to drive their Attachments,
* Notes and Parameters tab notification dots from one request, instead of each
* tab independently querying its own list endpoint just to read a count.
*/
export function useInstanceInfo({
modelType,
modelId
}: {
modelType?: ModelType;
modelId?: number;
}) {
const api = useApi();
const query = useQuery<InstanceInfo>({
queryKey: ['instance-info', modelType, modelId],
enabled: !!modelType && !!modelId,
// These counts only drive tab notification dots (not displayed as numbers
// anywhere), so - matching the staleTime PanelGroup already uses for the
// dots themselves - a stale value for a few minutes is an acceptable
// trade-off against refetching on every page navigation/remount.
staleTime: 5 * 60 * 1000,
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.instance_info), {
params: { model_type: modelType, model_id: modelId }
})
.then((response) => response.data ?? emptyInstanceInfo);
}
});
return {
instanceInfo: query.data ?? emptyInstanceInfo,
instanceInfoQuery: query
};
}
@@ -15,6 +15,7 @@ import {
IconList,
IconListDetails,
IconMail,
IconNotes,
IconPackages,
IconPhoto,
IconPlugConnected,
@@ -73,6 +74,8 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel')));
const ScimManagementPanel = Loadable(
lazy(() => import('./ScimManagementPanel'))
);
@@ -216,6 +219,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`,
@@ -316,6 +326,7 @@ export default function AdminCenter() {
'selection-lists',
'parameters',
'category-parameters',
'notes',
'location-types',
'stocktake'
]
@@ -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>
);
}
+12 -3
View File
@@ -49,6 +49,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@@ -231,6 +232,11 @@ export default function BuildDetail() {
refetchOnMount: true
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.build,
modelId: build?.pk
});
const buildPanels: PanelType[] = useMemo(() => {
return [
{
@@ -357,22 +363,25 @@ export default function BuildDetail() {
},
ParametersPanel({
model_type: ModelType.build,
model_id: build.pk
model_id: build.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.build,
model_id: build.pk
model_id: build.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.build,
model_id: build.pk,
has_note: !!build.notes
note_count: instanceInfo.note_count
})
];
}, [
build,
id,
user,
instanceInfo,
buildStatus,
globalSettings,
@@ -41,6 +41,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import { useUserState } from '../../states/UserState';
import { AddressTable } from '../../tables/company/AddressTable';
import { ContactTable } from '../../tables/company/ContactTable';
@@ -80,6 +81,11 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
refetchOnMount: true
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.company,
modelId: company?.pk
});
const detailsPanel = instanceQuery.isFetching ? (
<Skeleton />
) : (
@@ -184,19 +190,21 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
},
ParametersPanel({
model_type: ModelType.company,
model_id: company?.pk
model_id: company?.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.company,
model_id: company.pk
model_id: company.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.company,
model_id: company.pk,
has_note: !!company.notes
note_count: instanceInfo.note_count
})
];
}, [id, company, user]);
}, [id, company, user, instanceInfo]);
const editCompany = useEditApiFormModal({
url: ApiEndpoints.company_list,
@@ -33,6 +33,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import { useUserState } from '../../states/UserState';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@@ -58,6 +59,11 @@ export default function ManufacturerPartDetail() {
}
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.manufacturerpart,
modelId: manufacturerPart?.pk
});
const panels: PanelType[] = useMemo(() => {
return [
{
@@ -102,19 +108,21 @@ export default function ManufacturerPartDetail() {
},
ParametersPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk
model_id: manufacturerPart?.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk
model_id: manufacturerPart?.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.manufacturerpart,
model_id: manufacturerPart?.pk,
has_note: !!manufacturerPart?.notes
note_count: instanceInfo.note_count
})
];
}, [user, manufacturerPart]);
}, [user, manufacturerPart, instanceInfo]);
const editManufacturerPartFields = useManufacturerPartFields();
@@ -37,6 +37,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import { useUserState } from '../../states/UserState';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable';
@@ -66,6 +67,11 @@ export default function SupplierPartDetail() {
}
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.supplierpart,
modelId: supplierPart?.pk
});
const panels: PanelType[] = useMemo(() => {
return [
{
@@ -119,19 +125,21 @@ export default function SupplierPartDetail() {
},
ParametersPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk
model_id: supplierPart?.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk
model_id: supplierPart?.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.supplierpart,
model_id: supplierPart?.pk,
has_note: !!supplierPart?.notes
note_count: instanceInfo.note_count
})
];
}, [supplierPart]);
}, [supplierPart, instanceInfo]);
const supplierPartActions = useMemo(() => {
return [
@@ -39,6 +39,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@@ -80,6 +81,11 @@ export default function CategoryDetail() {
}
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.partcategory,
modelId: category?.pk
});
const stockOperationProps: StockOperationProps = useMemo(() => {
return {
refresh: refreshInstance,
@@ -254,7 +260,8 @@ export default function CategoryDetail() {
ParametersPanel({
model_type: ModelType.partcategory,
model_id: category?.pk,
hidden: !id || !category.pk
hidden: !id || !category.pk,
parameter_count: instanceInfo.parameter_count
}),
{
name: 'category_parameters',
@@ -264,7 +271,7 @@ export default function CategoryDetail() {
content: <PartCategoryTemplateTable categoryId={category?.pk} />
}
],
[category, id, partsView]
[category, id, partsView, instanceInfo]
);
const breadcrumbs = useMemo(
+12 -3
View File
@@ -78,6 +78,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import {
useGlobalSettingsState,
@@ -182,6 +183,11 @@ export default function PartDetail() {
refetchOnMount: true
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.part,
modelId: part?.pk
});
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
@@ -487,6 +493,7 @@ export default function PartDetail() {
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails />,
notification_dot: instanceInfo.parameter_count ? 'info' : null,
content: (
<>
{lockingEnabled && part.locked && (
@@ -509,12 +516,13 @@ export default function PartDetail() {
},
AttachmentPanel({
model_type: ModelType.part,
model_id: part?.pk
model_id: part?.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.part,
model_id: part?.pk,
has_note: !!part?.notes
note_count: instanceInfo.note_count
})
];
}, [
@@ -525,7 +533,8 @@ export default function PartDetail() {
userSettings,
bomInformation,
revisionSelector,
refreshInstance
refreshInstance,
instanceInfo
]);
const breadcrumbs = useMemo(() => {
@@ -34,6 +34,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@@ -65,6 +66,11 @@ export default function PurchaseOrderDetail() {
refetchOnMount: true
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.purchaseorder,
modelId: order?.pk
});
const orderCurrency = useMemo(
() =>
order.order_currency ||
@@ -198,16 +204,18 @@ export default function PurchaseOrderDetail() {
},
ParametersPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk
model_id: order.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk
model_id: order.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.purchaseorder,
model_id: order.pk,
has_note: !!order.notes,
note_count: instanceInfo.note_count,
// TODO @matmair - change API to include a "locked" attribute that we can check here
editable:
order.status == poStatus.COMPLETE &&
@@ -216,7 +224,7 @@ export default function PurchaseOrderDetail() {
: undefined
})
];
}, [order, id, user]);
}, [order, id, user, instanceInfo]);
const issueOrder = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.purchase_order_issue, order.pk),
@@ -34,6 +34,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useInstanceInfo } from '../../hooks/UseInstanceInfo';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@@ -64,6 +65,11 @@ export default function ReturnOrderDetail() {
}
});
const { instanceInfo } = useInstanceInfo({
modelType: ModelType.returnorder,
modelId: order?.pk
});
const roStatus = useStatusCodes({ modelType: ModelType.returnorder });
const orderOpen = useMemo(() => {
@@ -150,19 +156,21 @@ export default function ReturnOrderDetail() {
},
ParametersPanel({
model_type: ModelType.returnorder,
model_id: order.pk
model_id: order.pk,
parameter_count: instanceInfo.parameter_count
}),
AttachmentPanel({
model_type: ModelType.returnorder,
model_id: order.pk
model_id: order.pk,
attachment_count: instanceInfo.attachment_count
}),
NotesPanel({
model_type: ModelType.returnorder,
model_id: order.pk,
has_note: !!order.notes
note_count: instanceInfo.note_count
})
];
}, [order, id, user]);
}, [order, id, user, instanceInfo]);
const orderBadges: ReactNode[] = useMemo(() => {
return instanceQuery.isLoading

Some files were not shown because too many files have changed in this diff Show More