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
This commit is contained in:
Oliver 2023-04-19 13:08:26 +10:00 committed by GitHub
parent 2623c22b7e
commit 5cd74c4190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 441 additions and 63 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575
- Adds barcode support for PurchaseOrder model - Adds barcode support for PurchaseOrder model
- Adds barcode support for ReturnOrder model - Adds barcode support for ReturnOrder model

View File

@ -1161,3 +1161,20 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr
decimal_places=decimal_places, decimal_places=decimal_places,
include_symbol=include_symbol, 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)]

View File

@ -24,7 +24,7 @@ from mptt.models import MPTTModel, TreeForeignKey
import InvenTree.format import InvenTree.format
import InvenTree.helpers import InvenTree.helpers
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField
from InvenTree.sanitizer import sanitize_svg from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -670,6 +670,27 @@ class InvenTreeTree(MPTTModel):
return "{path} - {desc}".format(path=self.pathstring, desc=self.description) 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): class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class. """A mixin class for adding barcode functionality to a model class.

View File

@ -306,6 +306,20 @@ class TestHelpers(TestCase):
# Download a valid image (should not throw an error) # Download a valid image (should not throw an error)
helpers.download_image_from_url(large_img, timeout=10) 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): class TestQuoteWrap(TestCase):
"""Tests for string wrapping.""" """Tests for string wrapping."""

View File

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

View File

@ -23,7 +23,7 @@ from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, normalize, notify_responsible 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 from build.validators import generate_next_build_reference, validate_build_order_reference
@ -42,7 +42,7 @@ import stock.models
import users.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. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes: Attributes:
@ -293,10 +293,6 @@ class Build(MPTTModel, InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMi
blank=True, help_text=_('Link to external URL') blank=True, help_text=_('Link to external URL')
) )
notes = InvenTree.fields.InvenTreeNotesField(
help_text=_('Extra build notes')
)
priority = models.PositiveIntegerField( priority = models.PositiveIntegerField(
verbose_name=_('Build Priority'), verbose_name=_('Build Priority'),
default=0, default=0,

View File

@ -21,8 +21,8 @@ from InvenTree.api import BulkDeleteMixin
from InvenTree.config import CONFIG_LOOKUPS from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI, from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateDestroyAPI) RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from InvenTree.permissions import IsSuperuser from InvenTree.permissions import IsSuperuser
from plugin.models import NotificationUserSetting from plugin.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer from plugin.serializers import NotificationUserSettingSerializer
@ -440,6 +440,20 @@ class ConfigDetail(RetrieveAPI):
return {key: value} 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 = [ settings_api_urls = [
# User settings # User settings
re_path(r'^user/', include([ re_path(r'^user/', include([
@ -473,6 +487,9 @@ common_api_urls = [
# Webhooks # Webhooks
path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'), path('webhook/<slug:endpoint>/', 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 # Currencies
re_path(r'^currency/', include([ re_path(r'^currency/', include([
re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'), re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'),

View File

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

View File

@ -2642,3 +2642,27 @@ class NewsFeedEntry(models.Model):
help_text=_('Was this news item read?'), help_text=_('Was this news item read?'),
default=False 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)

View File

@ -5,9 +5,10 @@ from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting, from common.models import (InvenTreeSetting, InvenTreeUserSetting,
NewsFeedEntry, NotificationMessage) NewsFeedEntry, NotesImage, NotificationMessage)
from InvenTree.helpers import construct_absolute_url, get_objectreference from InvenTree.helpers import construct_absolute_url, get_objectreference
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import (InvenTreeImageSerializerField,
InvenTreeModelSerializer)
class SettingsSerializer(InvenTreeModelSerializer): class SettingsSerializer(InvenTreeModelSerializer):
@ -230,3 +231,25 @@ class ConfigSerializer(serializers.Serializer):
if not isinstance(instance, str): if not isinstance(instance, str):
instance = list(instance.keys())[0] instance = list(instance.keys())[0]
return {'key': instance, **self.instance[instance]} 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)

View File

@ -1,14 +1,18 @@
"""Tasks (processes that get offloaded) for common app.""" """Tasks (processes that get offloaded) for common app."""
import logging import logging
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError, OperationalError
from django.utils import timezone
import feedparser import feedparser
from InvenTree.helpers import getModelsWithMixin
from InvenTree.models import InvenTreeNotesMixin
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -26,7 +30,7 @@ def delete_old_notifications():
logger.info("Could not perform 'delete_old_notifications' - App registry not ready") logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
return return
before = datetime.now() - timedelta(days=90) before = timezone.now() - timedelta(days=90)
# Delete notification records before the specified date # Delete notification records before the specified date
NotificationEntry.objects.filter(updated__lte=before).delete() NotificationEntry.objects.filter(updated__lte=before).delete()
@ -72,3 +76,61 @@ def update_news_feed():
pass pass
logger.info('update_news_feed: Sync done') 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))

View File

@ -1,5 +1,6 @@
"""Tests for mechanisms in common.""" """Tests for mechanisms in common."""
import io
import json import json
import time import time
from datetime import timedelta from datetime import timedelta
@ -7,9 +8,12 @@ from http import HTTPStatus
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
import PIL
from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
from InvenTree.helpers import InvenTreeTestCase, str2bool from InvenTree.helpers import InvenTreeTestCase, str2bool
from plugin import registry from plugin import registry
@ -17,8 +21,8 @@ from plugin.models import NotificationUserSetting
from .api import WebhookView from .api import WebhookView
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
NotificationEntry, NotificationMessage, WebhookEndpoint, NotesImage, NotificationEntry, NotificationMessage,
WebhookMessage) WebhookEndpoint, WebhookMessage)
CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_JSON = 'application/json'
@ -935,3 +939,65 @@ class CurrencyAPITests(InvenTreeAPITestCase):
time.sleep(10) time.sleep(10)
raise TimeoutError("Could not refresh currency exchange data after 5 attempts") 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)

View File

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

View File

@ -25,7 +25,8 @@ import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField 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 InvenTree.status_codes import PurchaseOrderStatus
from plugin.models import MetadataMixin from plugin.models import MetadataMixin
@ -55,7 +56,7 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn) return os.path.join(base, fn)
class Company(MetadataMixin, models.Model): class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
"""A Company object represents an external company. """A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination) 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'), 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_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?')) is_supplier = models.BooleanField(default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?'))

View File

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

View File

@ -32,11 +32,11 @@ from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, Contact, SupplierPart from company.models import Company, Contact, SupplierPart
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField,
InvenTreeURLField, RoundingDecimalField) RoundingDecimalField)
from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.helpers import decimal2string, getSetting, notify_responsible
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
ReferenceIndexingMixin) InvenTreeNotesMixin, ReferenceIndexingMixin)
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
ReturnOrderStatus, SalesOrderStatus, ReturnOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus) StockHistoryCode, StockStatus)
@ -131,7 +131,7 @@ class TotalPriceMixin(models.Model):
return total return total
class Order(InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin): class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin):
"""Abstract model for an order. """Abstract model for an order.
Instances of this class: Instances of this class:
@ -234,8 +234,6 @@ class Order(InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin):
related_name='+', related_name='+',
) )
notes = InvenTreeNotesField(help_text=_('Order notes'))
@classmethod @classmethod
def get_status_class(cls): def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model""" """Return the enumeration class which represents the 'status' field for this model"""
@ -1330,7 +1328,7 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity 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. """The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object - Points to a single SalesOrder object
@ -1389,8 +1387,6 @@ class SalesOrderShipment(MetadataMixin, models.Model):
default='1', default='1',
) )
notes = InvenTreeNotesField(help_text=_('Shipment notes'))
tracking_number = models.CharField( tracking_number = models.CharField(
max_length=100, max_length=100,
blank=True, blank=True,

View File

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

View File

@ -43,10 +43,11 @@ from common.models import InvenTreeSetting
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree import helpers, validators 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.helpers import decimal2money, decimal2string, normalize
from InvenTree.models import (DataImportMixin, InvenTreeAttachment, from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
InvenTreeBarcodeMixin, InvenTreeTree) InvenTreeBarcodeMixin, InvenTreeNotesMixin,
InvenTreeTree)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus) SalesOrderStatus)
from order import models as OrderModels from order import models as OrderModels
@ -338,7 +339,7 @@ class PartManager(TreeManager):
@cleanup.ignore @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. """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. 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'), verbose_name=_('Virtual'),
help_text=_('Is this a virtual part, such as a software product or license?')) 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_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, bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,

View File

@ -11,12 +11,8 @@ import json
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import build.models from InvenTree.helpers import getModelsWithMixin, hash_barcode
import company.models from InvenTree.models import InvenTreeBarcodeMixin
import order.models
import part.models
import stock.models
from InvenTree.helpers import hash_barcode
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin from plugin.mixins import BarcodeMixin
@ -34,16 +30,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
def get_supported_barcode_models(): def get_supported_barcode_models():
"""Returns a list of database models which support barcode functionality""" """Returns a list of database models which support barcode functionality"""
return [ return getModelsWithMixin(InvenTreeBarcodeMixin)
build.models.Build,
company.models.SupplierPart,
order.models.PurchaseOrder,
order.models.ReturnOrder,
order.models.SalesOrder,
part.models.Part,
stock.models.StockItem,
stock.models.StockLocation,
]
def format_matched_response(self, label, model, instance): def format_matched_response(self, label, model, instance):
"""Format a response for the scanned data""" """Format a response for the scanned data"""

View File

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

View File

@ -29,10 +29,9 @@ import InvenTree.tasks
import label.models import label.models
import report.models import report.models
from company import models as CompanyModels from company import models as CompanyModels
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
InvenTreeURLField)
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
InvenTreeTree, extract_int) InvenTreeNotesMixin, InvenTreeTree, extract_int)
from InvenTree.status_codes import (SalesOrderStatus, StockHistoryCode, from InvenTree.status_codes import (SalesOrderStatus, StockHistoryCode,
StockStatus) StockStatus)
from part import models as PartModels from part import models as PartModels
@ -279,7 +278,7 @@ def default_delete_on_deplete():
return True 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. """A StockItem object represents a quantity of physical instances of a part.
Attributes: Attributes:
@ -800,8 +799,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
"""Return the text representation of the status field""" """Return the text representation of the status field"""
return StockStatus.text(self.status) return StockStatus.text(self.status)
notes = InvenTreeNotesField(help_text=_('Stock Item Notes'))
purchase_price = InvenTreeModelMoneyField( purchase_price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=6, decimal_places=6,

View File

@ -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={}) { function setupNotesField(element, url, options={}) {
var editable = options.editable || false; var editable = options.editable || false;
@ -419,12 +423,24 @@ function setupNotesField(element, url, options={}) {
element: document.getElementById(element), element: document.getElementById(element),
initialValue: initial, initialValue: initial,
toolbar: toolbar_icons, 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: [], shortcuts: [],
renderingConfig: {
markedOptions: {
sanitize: true,
}
}
}); });
@ -460,12 +476,15 @@ function setupNotesField(element, url, options={}) {
data[options.notes_field || 'notes'] = mde.value(); data[options.notes_field || 'notes'] = mde.value();
$('#save-notes').find('#save-icon').removeClass('fa-save').addClass('fa-spin fa-spinner');
inventreePut(url, data, { inventreePut(url, data, {
method: 'PATCH', method: 'PATCH',
success: function(response) { 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) { error: function(xhr) {
$('#save-notes').find('#save-icon').removeClass('fa-spin fa-spinner').addClass('fa-times-circle icon-red');
showApiError(xhr, url); showApiError(xhr, url);
} }
}); });

View File

@ -1,8 +1,8 @@
{% load i18n %} {% load i18n %}
<button type='button' id='edit-notes' title='{% trans "Edit" %}' class='btn btn-primary'> <button type='button' id='edit-notes' title='{% trans "Edit" %}' class='btn btn-primary'>
<span class='fas fa-edit'></span> {% trans "Edit" %} <span id='edit-icon' class='fas fa-edit'></span> {% trans "Edit" %}
</button> </button>
<button type='button' id='save-notes' title='{% trans "Save" %}' class='btn btn-success' style='display: none;'> <button type='button' id='save-notes' title='{% trans "Save" %}' class='btn btn-success' style='display: none;'>
<span class='fas fa-save'></span> {% trans "Save" %} <span id='save-icon' class='fas fa-save'></span> {% trans "Save" %}
</button> </button>

View File

@ -185,6 +185,7 @@ class RuleSet(models.Model):
'common_webhookmessage', 'common_webhookmessage',
'common_notificationentry', 'common_notificationentry',
'common_notificationmessage', 'common_notificationmessage',
'common_notesimage',
'users_owner', 'users_owner',
# Third-party tables # Third-party tables