mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55:44 +00:00
[PUI] Notes editor (#7284)
* Install mdxeditor * Setup basic toolbar * Refactoring * Add placeholder for image upload * Add fields to link uploaded notes to model instances * Add custom delete method for InvenTreeNotesMixin * Refactor CUI notes editor - Upload model type and model ID information * Enable image uplaod for PUI editor * Update <NotesEditor> component * Fix import * Add button to save notes * Prepend the host name to relative image URLs * Disable image resize * Add notifications * Add playwright tests * Enable "read-only" mode for notes * Typo fix * Styling updates to the editor * Update yarn.lock * Bump API version * Update migration * Remove duplicated value * Improve toggling between edit mode * Fix migration * Fix migration * Unit test updates - Click on the right buttons - Add 'key' properties * Remove extraneous key prop * fix api version * Add custom serializer mixin for 'notes' field - Pop the field for 'list' endpoints - Keep for detail * Update to NotesEditor * Add unit test
This commit is contained in:
@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 204
|
||||
INVENTREE_API_VERSION = 205
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v205 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7284
|
||||
- Added model_type and model_id fields to the "NotesImage" serializer
|
||||
|
||||
v204 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7393
|
||||
- Fixes previous API update which resulted in inconsistent ordering of currency codes
|
||||
|
||||
|
@ -1031,6 +1031,30 @@ class InvenTreeNotesMixin(models.Model):
|
||||
|
||||
abstract = True
|
||||
|
||||
def delete(self):
|
||||
"""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()
|
||||
|
||||
notes = InvenTree.fields.InvenTreeNotesField(
|
||||
verbose_name=_('Notes'), help_text=_('Markdown notes (optional)')
|
||||
)
|
||||
|
@ -18,6 +18,7 @@ from djmoney.utils import MONEY_CLASSES, get_currency_field_name
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
from taggit.serializers import TaggitSerializer
|
||||
@ -842,6 +843,23 @@ class DataFileExtractSerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
|
||||
class NotesFieldMixin:
|
||||
"""Serializer mixin for handling 'notes' fields.
|
||||
|
||||
The 'notes' field will be hidden in a LIST serializer,
|
||||
but available in a DETAIL serializer.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Remove 'notes' field from list views."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if hasattr(self, 'context'):
|
||||
if view := self.context.get('view', None):
|
||||
if issubclass(view.__class__, ListModelMixin):
|
||||
self.fields.pop('notes', None)
|
||||
|
||||
|
||||
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
"""Mixin class which allows downloading an 'image' from a remote URL.
|
||||
|
||||
|
@ -17,7 +17,7 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS
|
||||
from InvenTree.serializers import UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
from stock.generators import generate_batch_code
|
||||
@ -33,7 +33,7 @@ from users.serializers import OwnerSerializer
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
class Meta:
|
||||
|
@ -346,6 +346,8 @@ onPanelLoad('notes', function() {
|
||||
'build-notes',
|
||||
'{% url "api-build-detail" build.pk %}',
|
||||
{
|
||||
model_type: 'build',
|
||||
model_id: {{ build.pk }},
|
||||
{% if roles.build.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -479,6 +479,10 @@ class NotesImageList(ListCreateAPI):
|
||||
serializer_class = common.serializers.NotesImageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['user', 'model_type', 'model_id']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create (upload) a new notes image."""
|
||||
image = serializer.save()
|
||||
|
@ -52,11 +52,11 @@ def set_currencies(apps, schema_editor):
|
||||
setting = InvenTreeSetting.objects.filter(key=key).first()
|
||||
|
||||
if setting:
|
||||
print(f"Updating existing setting for currency codes")
|
||||
print(f"- Updating existing setting for currency codes")
|
||||
setting.value = value
|
||||
setting.save()
|
||||
else:
|
||||
print(f"Creating new setting for currency codes")
|
||||
print(f"- Creating new setting for currency codes")
|
||||
setting = InvenTreeSetting(key=key, value=value)
|
||||
setting.save()
|
||||
|
||||
|
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.2.12 on 2024-05-22 12:27
|
||||
|
||||
import common.validators
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0023_auto_20240602_1332'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notesimage',
|
||||
name='model_id',
|
||||
field=models.IntegerField(blank=True, default=None, help_text='Target model ID for this image', null=True),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
]
|
@ -9,7 +9,6 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from enum import Enum
|
||||
@ -35,7 +34,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.settings import CURRENCY_CHOICES
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
import build.validators
|
||||
@ -2955,7 +2953,7 @@ def rename_notes_image(instance, filename):
|
||||
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)
|
||||
Simply stores the image file, for use in the 'notes' field (of any models which support markdown).
|
||||
"""
|
||||
|
||||
image = models.ImageField(
|
||||
@ -2966,6 +2964,21 @@ class NotesImage(models.Model):
|
||||
|
||||
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.
|
||||
|
@ -281,7 +281,7 @@ class NotesImageSerializer(InvenTreeModelSerializer):
|
||||
"""Meta options for NotesImageSerializer."""
|
||||
|
||||
model = common_models.NotesImage
|
||||
fields = ['pk', 'image', 'user', 'date']
|
||||
fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id']
|
||||
|
||||
read_only_fields = ['date', 'user']
|
||||
|
||||
|
@ -1,8 +1,33 @@
|
||||
"""Validation helpers for common models."""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import InvenTree.helpers_model
|
||||
|
||||
|
||||
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.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."""
|
||||
|
@ -18,6 +18,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
InvenTreeTagModelSerializer,
|
||||
NotesFieldMixin,
|
||||
RemoteImageMixin,
|
||||
)
|
||||
from part.serializers import PartBriefSerializer
|
||||
@ -102,7 +103,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for Company object (full detail)."""
|
||||
|
||||
class Meta:
|
||||
|
@ -305,6 +305,8 @@
|
||||
'{% url "api-company-detail" company.pk %}',
|
||||
{
|
||||
editable: true,
|
||||
model_type: "company",
|
||||
model_id: {{ company.pk }},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -47,6 +47,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
NotesFieldMixin,
|
||||
)
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatusGroups,
|
||||
@ -198,7 +199,7 @@ class AbstractExtraLineMeta:
|
||||
|
||||
|
||||
class PurchaseOrderSerializer(
|
||||
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for a PurchaseOrder object."""
|
||||
|
||||
@ -768,7 +769,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
|
||||
class SalesOrderSerializer(
|
||||
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the SalesOrder model class."""
|
||||
|
||||
@ -1075,7 +1076,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
||||
class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrderShipment class."""
|
||||
|
||||
class Meta:
|
||||
@ -1536,7 +1537,7 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
|
||||
class ReturnOrderSerializer(
|
||||
AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the ReturnOrder model class."""
|
||||
|
||||
|
@ -120,6 +120,8 @@
|
||||
'order-notes',
|
||||
'{% url "api-po-detail" order.pk %}',
|
||||
{
|
||||
model_type: "purchaseorder",
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -175,6 +175,8 @@ onPanelLoad('order-notes', function() {
|
||||
'order-notes',
|
||||
'{% url "api-return-order-detail" order.pk %}',
|
||||
{
|
||||
model_type: 'returnorder',
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -190,6 +190,8 @@
|
||||
'order-notes',
|
||||
'{% url "api-so-detail" order.pk %}',
|
||||
{
|
||||
model_type: "salesorder",
|
||||
model_id: {{ order.pk }},
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -1179,7 +1179,6 @@ class PartMixin:
|
||||
queryset = Part.objects.all()
|
||||
|
||||
starred_parts = None
|
||||
|
||||
is_create = False
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
@ -580,6 +580,7 @@ class InitialSupplierSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PartSerializer(
|
||||
InvenTree.serializers.NotesFieldMixin,
|
||||
InvenTree.serializers.RemoteImageMixin,
|
||||
InvenTree.serializers.InvenTreeTagModelSerializer,
|
||||
):
|
||||
|
@ -404,6 +404,8 @@
|
||||
'part-notes',
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
model_type: "part",
|
||||
model_id: {{ part.pk }},
|
||||
editable: {% js_bool roles.part.change %},
|
||||
}
|
||||
);
|
||||
|
@ -1149,6 +1149,23 @@ 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)
|
||||
|
||||
|
||||
class PartCreationTests(PartAPITestBase):
|
||||
"""Tests for creating new Part instances via the API."""
|
||||
|
@ -283,7 +283,10 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
return data
|
||||
|
||||
|
||||
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
class StockItemSerializerBrief(
|
||||
InvenTree.serializers.NotesFieldMixin,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Brief serializers for a StockItem."""
|
||||
|
||||
class Meta:
|
||||
|
@ -208,6 +208,8 @@
|
||||
'stock-notes',
|
||||
'{% url "api-stock-detail" item.pk %}',
|
||||
{
|
||||
model_type: 'stockitem',
|
||||
model_id: {{ item.pk }},
|
||||
{% if roles.stock.change and user_owns_item %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
|
@ -482,6 +482,10 @@ function setupNotesField(element, url, options={}) {
|
||||
|
||||
form_data.append('image', imageFile);
|
||||
|
||||
// Add model type and ID to the form data
|
||||
form_data.append('model_type', options.model_type);
|
||||
form_data.append('model_id', options.model_id);
|
||||
|
||||
inventreeFormDataUpload('{% url "api-notes-image-list" %}', form_data, {
|
||||
success: function(response) {
|
||||
onSuccess(response.image);
|
||||
|
Reference in New Issue
Block a user