Skip to content

Commit

Permalink
[PUI] Notes editor (#7284)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored Jun 4, 2024
1 parent a5fa5f8 commit 2b8e8e5
Show file tree
Hide file tree
Showing 37 changed files with 2,534 additions and 308 deletions.
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/backend/InvenTree/InvenTree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,30 @@ class Meta:

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)')
)
Expand Down
18 changes: 18 additions & 0 deletions src/backend/InvenTree/InvenTree/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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
Expand Down Expand Up @@ -842,6 +843,23 @@ def save(self):
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.
Expand Down
4 changes: 2 additions & 2 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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
Expand All @@ -33,7 +33,7 @@
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment


class BuildSerializer(InvenTreeModelSerializer):
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
"""Serializes a Build object."""

class Meta:
Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/build/templates/build/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ <h4>{% trans "Build Notes" %}</h4>
'build-notes',
'{% url "api-build-detail" build.pk %}',
{
model_type: 'build',
model_id: {{ build.pk }},
{% if roles.build.change %}
editable: true,
{% else %}
Expand Down
4 changes: 4 additions & 0 deletions src/backend/InvenTree/common/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -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]),
),
]
19 changes: 16 additions & 3 deletions src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import json
import logging
import os
import re
import uuid
from datetime import timedelta, timezone
from enum import Enum
Expand All @@ -35,7 +34,6 @@

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
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/common/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ class Meta:
"""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']

Expand Down
25 changes: 25 additions & 0 deletions src/backend/InvenTree/common/validators.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion src/backend/InvenTree/company/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
NotesFieldMixin,
RemoteImageMixin,
)
from part.serializers import PartBriefSerializer
Expand Down Expand Up @@ -102,7 +103,7 @@ class Meta:
]


class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)."""

class Meta:
Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/company/templates/company/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ <h4>{% trans "Attachments" %}</h4>
'{% url "api-company-detail" company.pk %}',
{
editable: true,
model_type: "company",
model_id: {{ company.pk }},
}
);
});
Expand Down
9 changes: 5 additions & 4 deletions src/backend/InvenTree/order/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
NotesFieldMixin,
)
from order.status_codes import (
PurchaseOrderStatusGroups,
Expand Down Expand Up @@ -198,7 +199,7 @@ class AbstractExtraLineMeta:


class PurchaseOrderSerializer(
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
):
"""Serializer for a PurchaseOrder object."""

Expand Down Expand Up @@ -768,7 +769,7 @@ class Meta:


class SalesOrderSerializer(
TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
):
"""Serializer for the SalesOrder model class."""

Expand Down Expand Up @@ -1075,7 +1076,7 @@ def annotate_queryset(queryset):
)


class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
"""Serializer for the SalesOrderShipment class."""

class Meta:
Expand Down Expand Up @@ -1536,7 +1537,7 @@ class Meta:


class ReturnOrderSerializer(
AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
):
"""Serializer for the ReturnOrder model class."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ <h4>{% trans "Order Notes" %}</h4>
'order-notes',
'{% url "api-po-detail" order.pk %}',
{
model_type: "purchaseorder",
model_id: {{ order.pk }},
{% if roles.purchase_order.change %}
editable: true,
{% else %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ <h4>{% trans "Order Notes" %}</h4>
'order-notes',
'{% url "api-return-order-detail" order.pk %}',
{
model_type: 'returnorder',
model_id: {{ order.pk }},
{% if roles.purchase_order.change %}
editable: true,
{% else %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ <h4>{% trans "Order Notes" %}</h4>
'order-notes',
'{% url "api-so-detail" order.pk %}',
{
model_type: "salesorder",
model_id: {{ order.pk }},
{% if roles.purchase_order.change %}
editable: true,
{% else %}
Expand Down
1 change: 0 additions & 1 deletion src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,7 +1179,6 @@ class PartMixin:
queryset = Part.objects.all()

starred_parts = None

is_create = False

def get_queryset(self, *args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ def validate(self, data):


class PartSerializer(
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.RemoteImageMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
):
Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/part/templates/part/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ <h4>{% trans "Part Manufacturers" %}</h4>
'part-notes',
'{% url "api-part-detail" part.pk %}',
{
model_type: "part",
model_id: {{ part.pk }},
editable: {% js_bool roles.part.change %},
}
);
Expand Down
17 changes: 17 additions & 0 deletions src/backend/InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,23 @@ def test_date_filters(self):
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."""
Expand Down
Loading

0 comments on commit 2b8e8e5

Please sign in to comment.