2
0
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:
Oliver
2024-06-04 21:53:44 +10:00
committed by GitHub
parent a5fa5f8ac3
commit 2b8e8e52a8
37 changed files with 2534 additions and 308 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -305,6 +305,8 @@
'{% url "api-company-detail" company.pk %}',
{
editable: true,
model_type: "company",
model_id: {{ company.pk }},
}
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1179,7 +1179,6 @@ class PartMixin:
queryset = Part.objects.all()
starred_parts = None
is_create = False
def get_queryset(self, *args, **kwargs):

View File

@ -580,6 +580,7 @@ class InitialSupplierSerializer(serializers.Serializer):
class PartSerializer(
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.RemoteImageMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
):

View File

@ -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 %},
}
);

View File

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

View File

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

View File

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

View File

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