mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-04 03:48:54 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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/',
|
||||
|
||||
+1
-1
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
**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
|
||||
|
||||
@@ -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''
|
||||
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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
+26
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))',
|
||||
')',
|
||||
')',
|
||||
]
|
||||
|
||||
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 ',
|
||||
'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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
+34
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -206,6 +206,7 @@ def get_ruleset_ignore() -> list[str]:
|
||||
'common_inventreeusersetting',
|
||||
'common_notificationentry',
|
||||
'common_notificationmessage',
|
||||
'common_note',
|
||||
'common_notesimage',
|
||||
'common_projectcode',
|
||||
'common_webhookendpoint',
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user