mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
2623c22b7e
commit
5cd74c4190
@ -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
|
||||||
|
@ -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)]
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
19
InvenTree/build/migrations/0042_alter_build_notes.py
Normal file
19
InvenTree/build/migrations/0042_alter_build_notes.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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'),
|
||||||
|
27
InvenTree/common/migrations/0017_notesimage.py
Normal file
27
InvenTree/common/migrations/0017_notesimage.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
19
InvenTree/company/migrations/0056_alter_company_notes.py
Normal file
19
InvenTree/company/migrations/0056_alter_company_notes.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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?'))
|
||||||
|
34
InvenTree/order/migrations/0091_auto_20230419_0037.py
Normal file
34
InvenTree/order/migrations/0091_auto_20230419_0037.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
19
InvenTree/part/migrations/0105_alter_part_notes.py
Normal file
19
InvenTree/part/migrations/0105_alter_part_notes.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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"""
|
||||||
|
19
InvenTree/stock/migrations/0097_alter_stockitem_notes.py
Normal file
19
InvenTree/stock/migrations/0097_alter_stockitem_notes.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user