From 5cd74c4190d3c5261447572546f317c35f4cbac1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 19 Apr 2023 13:08:26 +1000 Subject: [PATCH] Support image uploads in the "notes" markdown fields (#4615) * Support image uploads in the "notes" markdown fields - Implemented using the existing EasyMDE library - Copy / paste support - Drag / drop support * Remove debug message * Updated API version * Better UX when saving notes * Pin PIP version (for testing) * Bug fixes - Fix typo - Use correct serializer type * Add unit testing * Update role permissions * Typo fix * Update migration file * Adds a notes mixin class to be used for refactoring * Refactor existing models with notes to use the new mixin * Add helper function for finding all model types with a certain mixin * Refactor barcode plugin to use new method * Typo fix * Add daily task to delete old / unused notes * Bug fix for barcode refactoring * Add unit testing for function --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/helpers.py | 17 +++++ InvenTree/InvenTree/models.py | 23 +++++- InvenTree/InvenTree/tests.py | 14 ++++ .../migrations/0042_alter_build_notes.py | 19 +++++ InvenTree/build/models.py | 8 +-- InvenTree/common/api.py | 21 +++++- .../common/migrations/0017_notesimage.py | 27 +++++++ InvenTree/common/models.py | 24 +++++++ InvenTree/common/serializers.py | 27 ++++++- InvenTree/common/tasks.py | 64 ++++++++++++++++- InvenTree/common/tests.py | 70 ++++++++++++++++++- .../migrations/0056_alter_company_notes.py | 19 +++++ InvenTree/company/models.py | 7 +- .../migrations/0091_auto_20230419_0037.py | 34 +++++++++ InvenTree/order/models.py | 14 ++-- .../part/migrations/0105_alter_part_notes.py | 19 +++++ InvenTree/part/models.py | 9 ++- .../builtin/barcodes/inventree_barcode.py | 19 +---- .../migrations/0097_alter_stockitem_notes.py | 19 +++++ InvenTree/stock/models.py | 9 +-- InvenTree/templates/js/translated/helpers.js | 31 ++++++-- InvenTree/templates/notes_buttons.html | 4 +- InvenTree/users/models.py | 1 + 24 files changed, 441 insertions(+), 63 deletions(-) create mode 100644 InvenTree/build/migrations/0042_alter_build_notes.py create mode 100644 InvenTree/common/migrations/0017_notesimage.py create mode 100644 InvenTree/company/migrations/0056_alter_company_notes.py create mode 100644 InvenTree/order/migrations/0091_auto_20230419_0037.py create mode 100644 InvenTree/part/migrations/0105_alter_part_notes.py create mode 100644 InvenTree/stock/migrations/0097_alter_stockitem_notes.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 33222ebe75..cbcc6b5da2 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 107 +INVENTREE_API_VERSION = 108 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v108 -> 2023-04-17 : https://github.com/inventree/InvenTree/pull/4615 + - Adds functionality to upload images for rendering in markdown notes + v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575 - Adds barcode support for PurchaseOrder model - Adds barcode support for ReturnOrder model diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 8fabbdc0aa..d985209bb8 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -1161,3 +1161,20 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr decimal_places=decimal_places, include_symbol=include_symbol, ) + + +def getModelsWithMixin(mixin_class) -> list: + """Return a list of models that inherit from the given mixin class. + + Args: + mixin_class: The mixin class to search for + + Returns: + List of models that inherit from the given mixin class + """ + + from django.contrib.contenttypes.models import ContentType + + db_models = [x.model_class() for x in ContentType.objects.all() if x is not None] + + return [x for x in db_models if x is not None and issubclass(x, mixin_class)] diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 41ade9f1c6..1098002b3e 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -24,7 +24,7 @@ from mptt.models import MPTTModel, TreeForeignKey import InvenTree.format import InvenTree.helpers from common.models import InvenTreeSetting -from InvenTree.fields import InvenTreeURLField +from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField from InvenTree.sanitizer import sanitize_svg logger = logging.getLogger('inventree') @@ -670,6 +670,27 @@ class InvenTreeTree(MPTTModel): return "{path} - {desc}".format(path=self.pathstring, desc=self.description) +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 + + notes = InvenTreeNotesField( + verbose_name=_('Notes'), + help_text=_('Markdown notes (optional)'), + ) + + class InvenTreeBarcodeMixin(models.Model): """A mixin class for adding barcode functionality to a model class. diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 2c7f3e726c..c2093ffac7 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -306,6 +306,20 @@ class TestHelpers(TestCase): # Download a valid image (should not throw an error) helpers.download_image_from_url(large_img, timeout=10) + def test_model_mixin(self): + """Test the getModelsWithMixin function""" + + from InvenTree.models import InvenTreeBarcodeMixin + + models = helpers.getModelsWithMixin(InvenTreeBarcodeMixin) + + self.assertIn(Part, models) + self.assertIn(StockLocation, models) + self.assertIn(StockItem, models) + + self.assertNotIn(PartCategory, models) + self.assertNotIn(InvenTreeSetting, models) + class TestQuoteWrap(TestCase): """Tests for string wrapping.""" diff --git a/InvenTree/build/migrations/0042_alter_build_notes.py b/InvenTree/build/migrations/0042_alter_build_notes.py new file mode 100644 index 0000000000..ed2bc9b411 --- /dev/null +++ b/InvenTree/build/migrations/0042_alter_build_notes.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-19 00:37 + +import InvenTree.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0041_alter_build_title'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 235897ef4b..abca3e96b9 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -23,7 +23,7 @@ from rest_framework import serializers from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, normalize, notify_responsible -from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin, ReferenceIndexingMixin +from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin, InvenTreeNotesMixin, ReferenceIndexingMixin from build.validators import generate_next_build_reference, validate_build_order_reference @@ -42,7 +42,7 @@ import stock.models import users.models -class Build(MPTTModel, InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin): +class Build(MPTTModel, InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -293,10 +293,6 @@ class Build(MPTTModel, InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMi blank=True, help_text=_('Link to external URL') ) - notes = InvenTree.fields.InvenTreeNotesField( - help_text=_('Extra build notes') - ) - priority = models.PositiveIntegerField( verbose_name=_('Build Priority'), default=0, diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 80e509173f..d9070702a7 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -21,8 +21,8 @@ from InvenTree.api import BulkDeleteMixin from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER from InvenTree.helpers import inheritors -from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI, - RetrieveUpdateDestroyAPI) +from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI, + RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) from InvenTree.permissions import IsSuperuser from plugin.models import NotificationUserSetting from plugin.serializers import NotificationUserSettingSerializer @@ -440,6 +440,20 @@ class ConfigDetail(RetrieveAPI): return {key: value} +class NotesImageList(ListCreateAPI): + """List view for all notes images.""" + + queryset = common.models.NotesImage.objects.all() + serializer_class = common.serializers.NotesImageSerializer + permission_classes = [permissions.IsAuthenticated, ] + + def perform_create(self, serializer): + """Create (upload) a new notes image""" + image = serializer.save() + image.user = self.request.user + image.save() + + settings_api_urls = [ # User settings re_path(r'^user/', include([ @@ -473,6 +487,9 @@ common_api_urls = [ # Webhooks path('webhook//', WebhookView.as_view(), name='api-webhook'), + # Uploaded images for notes + re_path(r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'), + # Currencies re_path(r'^currency/', include([ re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'), diff --git a/InvenTree/common/migrations/0017_notesimage.py b/InvenTree/common/migrations/0017_notesimage.py new file mode 100644 index 0000000000..c9e08af461 --- /dev/null +++ b/InvenTree/common/migrations/0017_notesimage.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.18 on 2023-04-17 05:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +import common.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('common', '0016_alter_notificationentry_updated'), + ] + + operations = [ + migrations.CreateModel( + name='NotesImage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(help_text='Image file', upload_to=common.models.rename_notes_image, verbose_name='Image')), + ('date', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index c5123a7e72..3c751e8009 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -2642,3 +2642,27 @@ class NewsFeedEntry(models.Model): help_text=_('Was this news item read?'), default=False ) + + +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) diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 7b6998da5a..2d9f20c9c2 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -5,9 +5,10 @@ from django.urls import reverse from rest_framework import serializers from common.models import (InvenTreeSetting, InvenTreeUserSetting, - NewsFeedEntry, NotificationMessage) + NewsFeedEntry, NotesImage, NotificationMessage) from InvenTree.helpers import construct_absolute_url, get_objectreference -from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import (InvenTreeImageSerializerField, + InvenTreeModelSerializer) class SettingsSerializer(InvenTreeModelSerializer): @@ -230,3 +231,25 @@ class ConfigSerializer(serializers.Serializer): if not isinstance(instance, str): instance = list(instance.keys())[0] return {'key': instance, **self.instance[instance]} + + +class NotesImageSerializer(InvenTreeModelSerializer): + """Serializer for the NotesImage model.""" + + class Meta: + """Meta options for NotesImageSerializer.""" + + model = NotesImage + fields = [ + 'pk', + 'image', + 'user', + 'date', + ] + + read_only_fields = [ + 'date', + 'user', + ] + + image = InvenTreeImageSerializerField(required=True) diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py index d3368f60e1..80e73e9a1c 100644 --- a/InvenTree/common/tasks.py +++ b/InvenTree/common/tasks.py @@ -1,14 +1,18 @@ """Tasks (processes that get offloaded) for common app.""" import logging +import os from datetime import datetime, timedelta from django.conf import settings from django.core.exceptions import AppRegistryNotReady from django.db.utils import IntegrityError, OperationalError +from django.utils import timezone import feedparser +from InvenTree.helpers import getModelsWithMixin +from InvenTree.models import InvenTreeNotesMixin from InvenTree.tasks import ScheduledTask, scheduled_task logger = logging.getLogger('inventree') @@ -26,7 +30,7 @@ def delete_old_notifications(): logger.info("Could not perform 'delete_old_notifications' - App registry not ready") return - before = datetime.now() - timedelta(days=90) + before = timezone.now() - timedelta(days=90) # Delete notification records before the specified date NotificationEntry.objects.filter(updated__lte=before).delete() @@ -72,3 +76,61 @@ def update_news_feed(): pass logger.info('update_news_feed: Sync done') + + +@scheduled_task(ScheduledTask.DAILY) +def delete_old_notes_images(): + """Remove old notes images from the database. + + Anything older than ~3 months is removed, unless it is linked to a note + """ + try: + from common.models import NotesImage + except AppRegistryNotReady: + logger.info("Could not perform 'delete_old_notes_images' - App registry not ready") + 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(f"Deleting note {note.image.path} - image file does not exist") + note.delete() + + note_classes = getModelsWithMixin(InvenTreeNotesMixin) + before = datetime.now() - timedelta(days=90) + + for note in NotesImage.objects.filter(date__lte=before): + # Find any images which are no longer referenced by a 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(f"Deleting note {img} - image file not linked to a note") + 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') + + images = os.listdir(notes_dir) + + 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(f"Deleting note {image} - image file not linked to a note") + os.remove(os.path.join(notes_dir, image)) diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index ce6f1bf283..738e175319 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -1,5 +1,6 @@ """Tests for mechanisms in common.""" +import io import json import time from datetime import timedelta @@ -7,9 +8,12 @@ from http import HTTPStatus from django.contrib.auth import get_user_model from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, TestCase from django.urls import reverse +import PIL + from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin from InvenTree.helpers import InvenTreeTestCase, str2bool from plugin import registry @@ -17,8 +21,8 @@ from plugin.models import NotificationUserSetting from .api import WebhookView from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, - NotificationEntry, NotificationMessage, WebhookEndpoint, - WebhookMessage) + NotesImage, NotificationEntry, NotificationMessage, + WebhookEndpoint, WebhookMessage) CONTENT_TYPE_JSON = 'application/json' @@ -935,3 +939,65 @@ class CurrencyAPITests(InvenTreeAPITestCase): time.sleep(10) raise TimeoutError("Could not refresh currency exchange data after 5 attempts") + + +class NotesImageTest(InvenTreeAPITestCase): + """Tests for uploading images to be used in markdown notes.""" + + def test_invalid_files(self): + """Test that invalid files are rejected.""" + + n = NotesImage.objects.count() + + # Test upload of a simple text file + response = self.post( + reverse('api-notes-image-list'), + data={ + 'image': SimpleUploadedFile('test.txt', b"this is not an image file", content_type='text/plain'), + }, + format='multipart', + expected_code=400 + ) + + self.assertIn("Upload a valid image", str(response.data['image'])) + + # Test upload of an invalid image file + response = self.post( + reverse('api-notes-image-list'), + data={ + 'image': SimpleUploadedFile('test.png', b"this is not an image file", content_type='image/png'), + }, + format='multipart', + expected_code=400, + ) + + self.assertIn("Upload a valid image", str(response.data['image'])) + + # 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() + + # Construct a simple image file + image = PIL.Image.new('RGB', (100, 100), color='red') + + with io.BytesIO() as output: + image.save(output, format='PNG') + contents = output.getvalue() + + response = self.post( + reverse('api-notes-image-list'), + data={ + 'image': SimpleUploadedFile('test.png', contents, content_type='image/png'), + }, + format='multipart', + expected_code=201 + ) + + print(response.data) + + # Check that a new file has been created + self.assertEqual(NotesImage.objects.count(), n + 1) diff --git a/InvenTree/company/migrations/0056_alter_company_notes.py b/InvenTree/company/migrations/0056_alter_company_notes.py new file mode 100644 index 0000000000..3ef0ea53b7 --- /dev/null +++ b/InvenTree/company/migrations/0056_alter_company_notes.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-19 00:37 + +import InvenTree.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0055_auto_20230317_0816'), + ] + + operations = [ + migrations.AlterField( + model_name='company', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 8155caeeea..e16cdffcdf 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -25,7 +25,8 @@ import InvenTree.tasks import InvenTree.validators from common.settings import currency_code_default from InvenTree.fields import InvenTreeURLField, RoundingDecimalField -from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin +from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, + InvenTreeNotesMixin) from InvenTree.status_codes import PurchaseOrderStatus from plugin.models import MetadataMixin @@ -55,7 +56,7 @@ def rename_company_image(instance, filename): return os.path.join(base, fn) -class Company(MetadataMixin, models.Model): +class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): """A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) @@ -140,8 +141,6 @@ class Company(MetadataMixin, models.Model): verbose_name=_('Image'), ) - notes = InvenTree.fields.InvenTreeNotesField(help_text=_("Company Notes")) - is_customer = models.BooleanField(default=False, verbose_name=_('is customer'), help_text=_('Do you sell items to this company?')) is_supplier = models.BooleanField(default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?')) diff --git a/InvenTree/order/migrations/0091_auto_20230419_0037.py b/InvenTree/order/migrations/0091_auto_20230419_0037.py new file mode 100644 index 0000000000..3e420c0d98 --- /dev/null +++ b/InvenTree/order/migrations/0091_auto_20230419_0037.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.18 on 2023-04-19 00:37 + +import InvenTree.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0090_auto_20230412_1752'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + migrations.AlterField( + model_name='returnorder', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + migrations.AlterField( + model_name='salesorder', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + migrations.AlterField( + model_name='salesordershipment', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 51d3cae0b9..a9c2ebd950 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -32,11 +32,11 @@ from common.notifications import InvenTreeNotificationBodies from common.settings import currency_code_default from company.models import Company, Contact, SupplierPart from InvenTree.exceptions import log_error -from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, - InvenTreeURLField, RoundingDecimalField) +from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, + RoundingDecimalField) from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - ReferenceIndexingMixin) + InvenTreeNotesMixin, ReferenceIndexingMixin) from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, ReturnOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) @@ -131,7 +131,7 @@ class TotalPriceMixin(models.Model): return total -class Order(InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin): +class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): """Abstract model for an order. Instances of this class: @@ -234,8 +234,6 @@ class Order(InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin): related_name='+', ) - notes = InvenTreeNotesField(help_text=_('Order notes')) - @classmethod def get_status_class(cls): """Return the enumeration class which represents the 'status' field for this model""" @@ -1330,7 +1328,7 @@ class SalesOrderLineItem(OrderLineItem): return self.shipped >= self.quantity -class SalesOrderShipment(MetadataMixin, models.Model): +class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model): """The SalesOrderShipment model represents a physical shipment made against a SalesOrder. - Points to a single SalesOrder object @@ -1389,8 +1387,6 @@ class SalesOrderShipment(MetadataMixin, models.Model): default='1', ) - notes = InvenTreeNotesField(help_text=_('Shipment notes')) - tracking_number = models.CharField( max_length=100, blank=True, diff --git a/InvenTree/part/migrations/0105_alter_part_notes.py b/InvenTree/part/migrations/0105_alter_part_notes.py new file mode 100644 index 0000000000..081ff7b6ef --- /dev/null +++ b/InvenTree/part/migrations/0105_alter_part_notes.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-19 00:37 + +import InvenTree.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0104_alter_part_description'), + ] + + operations = [ + migrations.AlterField( + model_name='part', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 89bbc5df26..b04dbf9e04 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -43,10 +43,11 @@ from common.models import InvenTreeSetting from common.settings import currency_code_default from company.models import SupplierPart from InvenTree import helpers, validators -from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField +from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2money, decimal2string, normalize from InvenTree.models import (DataImportMixin, InvenTreeAttachment, - InvenTreeBarcodeMixin, InvenTreeTree) + InvenTreeBarcodeMixin, InvenTreeNotesMixin, + InvenTreeTree) from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, SalesOrderStatus) from order import models as OrderModels @@ -338,7 +339,7 @@ class PartManager(TreeManager): @cleanup.ignore -class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): +class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel): """The Part object represents an abstract part, the 'concept' of an actual entity. An actual physical instance of a Part is a StockItem which is treated separately. @@ -1016,8 +1017,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): verbose_name=_('Virtual'), help_text=_('Is this a virtual part, such as a software product or license?')) - notes = InvenTreeNotesField(help_text=_('Part notes')) - bom_checksum = models.CharField(max_length=128, blank=True, verbose_name=_('BOM checksum'), help_text=_('Stored BOM checksum')) bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, diff --git a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py index 5d1d016139..398ad4cdd5 100644 --- a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py @@ -11,12 +11,8 @@ import json from django.utils.translation import gettext_lazy as _ -import build.models -import company.models -import order.models -import part.models -import stock.models -from InvenTree.helpers import hash_barcode +from InvenTree.helpers import getModelsWithMixin, hash_barcode +from InvenTree.models import InvenTreeBarcodeMixin from plugin import InvenTreePlugin from plugin.mixins import BarcodeMixin @@ -34,16 +30,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin): def get_supported_barcode_models(): """Returns a list of database models which support barcode functionality""" - return [ - build.models.Build, - company.models.SupplierPart, - order.models.PurchaseOrder, - order.models.ReturnOrder, - order.models.SalesOrder, - part.models.Part, - stock.models.StockItem, - stock.models.StockLocation, - ] + return getModelsWithMixin(InvenTreeBarcodeMixin) def format_matched_response(self, label, model, instance): """Format a response for the scanned data""" diff --git a/InvenTree/stock/migrations/0097_alter_stockitem_notes.py b/InvenTree/stock/migrations/0097_alter_stockitem_notes.py new file mode 100644 index 0000000000..d4bdbc6d5f --- /dev/null +++ b/InvenTree/stock/migrations/0097_alter_stockitem_notes.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-19 00:37 + +import InvenTree.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0096_auto_20230330_1121'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='notes', + field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 8ba973aa85..683d976a34 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -29,10 +29,9 @@ import InvenTree.tasks import label.models import report.models from company import models as CompanyModels -from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, - InvenTreeURLField) +from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeTree, extract_int) + InvenTreeNotesMixin, InvenTreeTree, extract_int) from InvenTree.status_codes import (SalesOrderStatus, StockHistoryCode, StockStatus) from part import models as PartModels @@ -279,7 +278,7 @@ def default_delete_on_deplete(): return True -class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, MPTTModel): +class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, common.models.MetaMixin, MPTTModel): """A StockItem object represents a quantity of physical instances of a part. Attributes: @@ -800,8 +799,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M """Return the text representation of the status field""" return StockStatus.text(self.status) - notes = InvenTreeNotesField(help_text=_('Stock Item Notes')) - purchase_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=6, diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 04b50d5cbd..4f7aa2de4b 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -380,6 +380,10 @@ function renderLink(text, url, options={}) { } +/* + * Configure an EasyMDE editor for the given element, + * allowing markdown editing of the notes field. + */ function setupNotesField(element, url, options={}) { var editable = options.editable || false; @@ -419,12 +423,24 @@ function setupNotesField(element, url, options={}) { element: document.getElementById(element), initialValue: initial, toolbar: toolbar_icons, + uploadImage: true, + imagePathAbsolute: true, + imageUploadFunction: function(imageFile, onSuccess, onError) { + // Attempt to upload the image to the InvenTree server + var form_data = new FormData(); + + form_data.append('image', imageFile); + + inventreeFormDataUpload('{% url "api-notes-image-list" %}', form_data, { + success: function(response) { + onSuccess(response.image); + }, + error: function(xhr, status, error) { + onError(error); + } + }); + }, shortcuts: [], - renderingConfig: { - markedOptions: { - sanitize: true, - } - } }); @@ -460,12 +476,15 @@ function setupNotesField(element, url, options={}) { data[options.notes_field || 'notes'] = mde.value(); + $('#save-notes').find('#save-icon').removeClass('fa-save').addClass('fa-spin fa-spinner'); + inventreePut(url, data, { method: 'PATCH', success: function(response) { - showMessage('{% trans "Notes updated" %}', {style: 'success'}); + $('#save-notes').find('#save-icon').removeClass('fa-spin fa-spinner').addClass('fa-check-circle'); }, error: function(xhr) { + $('#save-notes').find('#save-icon').removeClass('fa-spin fa-spinner').addClass('fa-times-circle icon-red'); showApiError(xhr, url); } }); diff --git a/InvenTree/templates/notes_buttons.html b/InvenTree/templates/notes_buttons.html index a4072074f9..5c808141bf 100644 --- a/InvenTree/templates/notes_buttons.html +++ b/InvenTree/templates/notes_buttons.html @@ -1,8 +1,8 @@ {% load i18n %} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 9d4948bdc7..b8d784c2af 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -185,6 +185,7 @@ class RuleSet(models.Model): 'common_webhookmessage', 'common_notificationentry', 'common_notificationmessage', + 'common_notesimage', 'users_owner', # Third-party tables