mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2694
This commit is contained in:
commit
e1b3de001f
@ -6,6 +6,7 @@ Main JSON interface views
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
@ -37,6 +38,7 @@ class InfoView(AjaxView):
|
||||
'instance': inventreeInstanceName(),
|
||||
'apiVersion': inventreeApiVersion(),
|
||||
'worker_running': is_worker_running(),
|
||||
'plugins_enabled': settings.PLUGINS_ENABLED,
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
|
@ -12,11 +12,17 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 32
|
||||
INVENTREE_API_VERSION = 34
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v34 -> 2022-03-25
|
||||
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
|
||||
|
||||
v33 -> 2022-03-24
|
||||
- Adds "plugins_enabled" information to root API endpoint
|
||||
|
||||
v32 -> 2022-03-19
|
||||
- Adds "parameters" detail to Part API endpoint (use ¶meters=true)
|
||||
- Adds ability to filter PartParameterTemplate API by Part instance
|
||||
|
@ -258,6 +258,19 @@
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Label Printing Actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
|
||||
<span class='fas fa-tags'></span> {% trans "Print labels" %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -468,6 +481,23 @@ inventreeGet(
|
||||
)
|
||||
});
|
||||
|
||||
$('#incomplete-output-print-label').click(function() {
|
||||
var outputs = $('#build-output-table').bootstrapTable('getSelections');
|
||||
|
||||
if (outputs.length == 0) {
|
||||
outputs = $('#build-output-table').bootstrapTable('getData');
|
||||
}
|
||||
|
||||
var stock_id_values = [];
|
||||
|
||||
outputs.forEach(function(output) {
|
||||
stock_id_values.push(output.pk);
|
||||
});
|
||||
|
||||
printStockItemLabels(stock_id_values);
|
||||
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
|
@ -79,7 +79,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
self.key = str(self.key).upper()
|
||||
|
||||
self.clean(**kwargs)
|
||||
self.validate_unique()
|
||||
self.validate_unique(**kwargs)
|
||||
|
||||
super().save()
|
||||
|
||||
@ -230,10 +230,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
return choices
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls, key, **kwargs):
|
||||
return {'key__iexact': key}
|
||||
|
||||
@classmethod
|
||||
def get_setting_object(cls, key, **kwargs):
|
||||
"""
|
||||
@ -247,29 +243,35 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
settings = cls.objects.all()
|
||||
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
}
|
||||
|
||||
# Filter by user
|
||||
user = kwargs.get('user', None)
|
||||
|
||||
if user is not None:
|
||||
settings = settings.filter(user=user)
|
||||
filters['user'] = user
|
||||
|
||||
try:
|
||||
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
|
||||
except (ValueError, cls.DoesNotExist):
|
||||
setting = None
|
||||
except (IntegrityError, OperationalError):
|
||||
setting = None
|
||||
# Filter by plugin
|
||||
plugin = kwargs.get('plugin', None)
|
||||
|
||||
plugin = kwargs.pop('plugin', None)
|
||||
|
||||
if plugin:
|
||||
if plugin is not None:
|
||||
from plugin import InvenTreePluginBase
|
||||
|
||||
if issubclass(plugin.__class__, InvenTreePluginBase):
|
||||
plugin = plugin.plugin_config()
|
||||
|
||||
filters['plugin'] = plugin
|
||||
kwargs['plugin'] = plugin
|
||||
|
||||
try:
|
||||
setting = settings.filter(**filters).first()
|
||||
except (ValueError, cls.DoesNotExist):
|
||||
setting = None
|
||||
except (IntegrityError, OperationalError):
|
||||
setting = None
|
||||
|
||||
# Setting does not exist! (Try to create it)
|
||||
if not setting:
|
||||
|
||||
@ -287,7 +289,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
try:
|
||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||
with transaction.atomic():
|
||||
setting.save()
|
||||
setting.save(**kwargs)
|
||||
except (IntegrityError, OperationalError):
|
||||
# It might be the case that the database isn't created yet
|
||||
pass
|
||||
@ -342,8 +344,26 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if change_user is not None and not change_user.is_staff:
|
||||
return
|
||||
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
}
|
||||
|
||||
user = kwargs.get('user', None)
|
||||
plugin = kwargs.get('plugin', None)
|
||||
|
||||
if user is not None:
|
||||
filters['user'] = user
|
||||
|
||||
if plugin is not None:
|
||||
from plugin import InvenTreePluginBase
|
||||
|
||||
if issubclass(plugin.__class__, InvenTreePluginBase):
|
||||
filters['plugin'] = plugin.plugin_config()
|
||||
else:
|
||||
filters['plugin'] = plugin
|
||||
|
||||
try:
|
||||
setting = cls.objects.get(**cls.get_filters(key, **kwargs))
|
||||
setting = cls.objects.get(**filters)
|
||||
except cls.DoesNotExist:
|
||||
|
||||
if create:
|
||||
@ -438,17 +458,37 @@ class BaseInvenTreeSetting(models.Model):
|
||||
validator(self.value)
|
||||
|
||||
def validate_unique(self, exclude=None, **kwargs):
|
||||
""" Ensure that the key:value pair is unique.
|
||||
"""
|
||||
Ensure that the key:value pair is unique.
|
||||
In addition to the base validators, this ensures that the 'key'
|
||||
is unique, using a case-insensitive comparison.
|
||||
|
||||
Note that sub-classes (UserSetting, PluginSetting) use other filters
|
||||
to determine if the setting is 'unique' or not
|
||||
"""
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
filters = {
|
||||
'key__iexact': self.key,
|
||||
}
|
||||
|
||||
user = getattr(self, 'user', None)
|
||||
plugin = getattr(self, 'plugin', None)
|
||||
|
||||
if user is not None:
|
||||
filters['user'] = user
|
||||
|
||||
if plugin is not None:
|
||||
filters['plugin'] = plugin
|
||||
|
||||
try:
|
||||
setting = self.__class__.objects.exclude(id=self.id).filter(**self.get_filters(self.key, **kwargs))
|
||||
# Check if a duplicate setting already exists
|
||||
setting = self.__class__.objects.filter(**filters).exclude(id=self.id)
|
||||
|
||||
if setting.exists():
|
||||
raise ValidationError({'key': _('Key string must be unique')})
|
||||
|
||||
except self.DoesNotExist:
|
||||
pass
|
||||
|
||||
@ -1200,6 +1240,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'NOTIFICATION_SEND_EMAILS': {
|
||||
'name': _('Enable email notifications'),
|
||||
'description': _('Allow sending of emails for event notifications'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
"LABEL_INLINE": {
|
||||
'name': _('Inline label display'),
|
||||
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
|
||||
@ -1305,16 +1352,9 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
def get_setting_object(cls, key, user):
|
||||
return super().get_setting_object(key, user=user)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
def validate_unique(self, exclude=None, **kwargs):
|
||||
return super().validate_unique(exclude=exclude, user=self.user)
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls, key, **kwargs):
|
||||
return {
|
||||
'key__iexact': key,
|
||||
'user__id': kwargs['user'].id
|
||||
}
|
||||
|
||||
def to_native_value(self):
|
||||
"""
|
||||
Return the "pythonic" value,
|
||||
|
@ -8,15 +8,19 @@ from allauth.account.models import EmailAddress
|
||||
from InvenTree.helpers import inheritors
|
||||
from InvenTree.ready import isImportingData
|
||||
from common.models import NotificationEntry, NotificationMessage
|
||||
from common.models import InvenTreeUserSetting
|
||||
|
||||
import InvenTree.tasks
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
# region notification classes
|
||||
# region base classes
|
||||
class NotificationMethod:
|
||||
"""
|
||||
Base class for notification methods
|
||||
"""
|
||||
|
||||
METHOD_NAME = ''
|
||||
CONTEXT_BUILTIN = ['name', 'message', ]
|
||||
CONTEXT_EXTRA = []
|
||||
@ -95,10 +99,8 @@ class SingleNotificationMethod(NotificationMethod):
|
||||
class BulkNotificationMethod(NotificationMethod):
|
||||
def send_bulk(self):
|
||||
raise NotImplementedError('The `send` method must be overriden!')
|
||||
# endregion
|
||||
|
||||
|
||||
# region implementations
|
||||
class EmailNotification(BulkNotificationMethod):
|
||||
METHOD_NAME = 'mail'
|
||||
CONTEXT_EXTRA = [
|
||||
@ -108,13 +110,26 @@ class EmailNotification(BulkNotificationMethod):
|
||||
]
|
||||
|
||||
def get_targets(self):
|
||||
"""
|
||||
Return a list of target email addresses,
|
||||
only for users which allow email notifications
|
||||
"""
|
||||
|
||||
allowed_users = []
|
||||
|
||||
for user in self.targets:
|
||||
allows_emails = InvenTreeUserSetting.get_setting('NOTIFICATION_SEND_EMAILS', user=user)
|
||||
|
||||
if allows_emails:
|
||||
allowed_users.append(user)
|
||||
|
||||
return EmailAddress.objects.filter(
|
||||
user__in=self.targets,
|
||||
user__in=allowed_users,
|
||||
)
|
||||
|
||||
def send_bulk(self):
|
||||
html_message = render_to_string(self.context['template']['html'], self.context)
|
||||
targets = self.targets.values_list('email', flat=True)
|
||||
targets = self.get_targets().values_list('email', flat=True)
|
||||
|
||||
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
|
||||
|
||||
@ -137,20 +152,27 @@ class UIMessageNotification(SingleNotificationMethod):
|
||||
message=self.context['message'],
|
||||
)
|
||||
return True
|
||||
# endregion
|
||||
# endregion
|
||||
|
||||
|
||||
def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_fnc=None, target_args=[], target_kwargs={}, context={}):
|
||||
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
||||
"""
|
||||
Send out an notification
|
||||
Send out a notification
|
||||
"""
|
||||
# check if data is importet currently
|
||||
|
||||
targets = kwargs.get('targets', None)
|
||||
target_fnc = kwargs.get('target_fnc', None)
|
||||
target_args = kwargs.get('target_args', [])
|
||||
target_kwargs = kwargs.get('target_kwargs', {})
|
||||
context = kwargs.get('context', {})
|
||||
delivery_methods = kwargs.get('delivery_methods', None)
|
||||
|
||||
# Check if data is importing currently
|
||||
if isImportingData():
|
||||
return
|
||||
|
||||
# Resolve objekt reference
|
||||
obj_ref_value = getattr(obj, obj_ref)
|
||||
|
||||
# Try with some defaults
|
||||
if not obj_ref_value:
|
||||
obj_ref_value = getattr(obj, 'pk')
|
||||
@ -175,7 +197,8 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_f
|
||||
logger.info(f"Sending notification '{category}' for '{str(obj)}'")
|
||||
|
||||
# Collect possible methods
|
||||
delivery_methods = inheritors(NotificationMethod)
|
||||
if delivery_methods is None:
|
||||
delivery_methods = inheritors(NotificationMethod)
|
||||
|
||||
for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]:
|
||||
logger.info(f"Triggering method '{method.METHOD_NAME}'")
|
||||
|
@ -1,10 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url, include
|
||||
from django.core.exceptions import ValidationError, FieldError
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
@ -12,8 +17,11 @@ from rest_framework import generics, filters
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.tasks import offload_task
|
||||
import common.models
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
from part.models import Part
|
||||
|
||||
@ -46,11 +54,43 @@ class LabelPrintMixin:
|
||||
Mixin for printing labels
|
||||
"""
|
||||
|
||||
def get_plugin(self, request):
|
||||
"""
|
||||
Return the label printing plugin associated with this request.
|
||||
This is provided in the url, e.g. ?plugin=myprinter
|
||||
|
||||
Requires:
|
||||
- settings.PLUGINS_ENABLED is True
|
||||
- matching plugin can be found
|
||||
- matching plugin implements the 'labels' mixin
|
||||
- matching plugin is enabled
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
return None
|
||||
|
||||
plugin_key = request.query_params.get('plugin', None)
|
||||
|
||||
for slug, plugin in registry.plugins.items():
|
||||
|
||||
if slug == plugin_key and plugin.mixin_enabled('labels'):
|
||||
|
||||
config = plugin.plugin_config()
|
||||
|
||||
if config and config.active:
|
||||
# Only return the plugin if it is enabled!
|
||||
return plugin
|
||||
|
||||
# No matches found
|
||||
return None
|
||||
|
||||
def print(self, request, items_to_print):
|
||||
"""
|
||||
Print this label template against a number of pre-validated items
|
||||
"""
|
||||
|
||||
# Check the request to determine if the user has selected a label printing plugin
|
||||
plugin = self.get_plugin(request)
|
||||
if len(items_to_print) == 0:
|
||||
# No valid items provided, return an error message
|
||||
data = {
|
||||
@ -66,6 +106,8 @@ class LabelPrintMixin:
|
||||
|
||||
label_name = "label.pdf"
|
||||
|
||||
label_names = []
|
||||
|
||||
# Merge one or more PDF files into a single download
|
||||
for item in items_to_print:
|
||||
label = self.get_object()
|
||||
@ -73,6 +115,8 @@ class LabelPrintMixin:
|
||||
|
||||
label_name = label.generate_filename(request)
|
||||
|
||||
label_names.append(label_name)
|
||||
|
||||
if debug_mode:
|
||||
outputs.append(label.render_as_string(request))
|
||||
else:
|
||||
@ -81,7 +125,51 @@ class LabelPrintMixin:
|
||||
if not label_name.endswith(".pdf"):
|
||||
label_name += ".pdf"
|
||||
|
||||
if debug_mode:
|
||||
if plugin is not None:
|
||||
"""
|
||||
Label printing is to be handled by a plugin,
|
||||
rather than being exported to PDF.
|
||||
|
||||
In this case, we do the following:
|
||||
|
||||
- Individually generate each label, exporting as an image file
|
||||
- Pass all the images through to the label printing plugin
|
||||
- Return a JSON response indicating that the printing has been offloaded
|
||||
|
||||
"""
|
||||
|
||||
# Label instance
|
||||
label_instance = self.get_object()
|
||||
|
||||
for output in outputs:
|
||||
"""
|
||||
For each output, we generate a temporary image file,
|
||||
which will then get sent to the printer
|
||||
"""
|
||||
|
||||
# Generate a png image at 300dpi
|
||||
(img_data, w, h) = output.get_document().write_png(resolution=300)
|
||||
|
||||
# Construct a BytesIO object, which can be read by pillow
|
||||
img_bytes = BytesIO(img_data)
|
||||
|
||||
image = Image.open(img_bytes)
|
||||
|
||||
# Offload a background task to print the provided label
|
||||
offload_task(
|
||||
'plugin.events.print_label',
|
||||
plugin.plugin_slug(),
|
||||
image,
|
||||
label_instance=label_instance,
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'plugin': plugin.plugin_slug(),
|
||||
'labels': label_names,
|
||||
})
|
||||
|
||||
elif debug_mode:
|
||||
"""
|
||||
Contatenate all rendered templates into a single HTML string,
|
||||
and return the string as a HTML response.
|
||||
@ -90,6 +178,7 @@ class LabelPrintMixin:
|
||||
html = "\n".join(outputs)
|
||||
|
||||
return HttpResponse(html)
|
||||
|
||||
else:
|
||||
"""
|
||||
Concatenate all rendered pages into a single PDF object,
|
||||
|
@ -13,6 +13,8 @@
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0mm;
|
||||
color: #000;
|
||||
background-color: #FFF;
|
||||
}
|
||||
|
||||
img {
|
||||
|
@ -5,7 +5,6 @@ import logging
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
import common.notifications
|
||||
|
@ -8,6 +8,7 @@ over and above the built-in Django tags.
|
||||
from datetime import date, datetime
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
@ -31,6 +32,9 @@ from plugin.models import PluginSetting
|
||||
register = template.Library()
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def define(value, *args, **kwargs):
|
||||
"""
|
||||
@ -57,8 +61,19 @@ def render_date(context, date_object):
|
||||
return None
|
||||
|
||||
if type(date_object) == str:
|
||||
|
||||
date_object = date_object.strip()
|
||||
|
||||
# Check for empty string
|
||||
if len(date_object) == 0:
|
||||
return None
|
||||
|
||||
# If a string is passed, first convert it to a datetime
|
||||
date_object = date.fromisoformat(date_object)
|
||||
try:
|
||||
date_object = date.fromisoformat(date_object)
|
||||
except ValueError:
|
||||
logger.warning(f"Tried to convert invalid date string: {date_object}")
|
||||
return None
|
||||
|
||||
# We may have already pre-cached the date format by calling this already!
|
||||
user_date_format = context.get('user_date_format', None)
|
||||
|
@ -9,6 +9,7 @@ from django.conf.urls import url, include
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.api import GlobalSettingsPermissions
|
||||
@ -22,6 +23,11 @@ class PluginList(generics.ListAPIView):
|
||||
- GET: Return a list of all PluginConfig objects
|
||||
"""
|
||||
|
||||
# Allow any logged in user to read this endpoint
|
||||
# This is necessary to allow certain functionality,
|
||||
# e.g. determining which label printing plugins are available
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
queryset = PluginConfig.objects.all()
|
||||
|
||||
|
@ -393,6 +393,42 @@ class AppMixin:
|
||||
return True
|
||||
|
||||
|
||||
class LabelPrintingMixin:
|
||||
"""
|
||||
Mixin which enables direct printing of stock labels.
|
||||
|
||||
Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer.
|
||||
|
||||
The plugin must also implement the print_label() function
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Label printing'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('labels', True, __class__)
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
"""
|
||||
Callback to print a single label
|
||||
|
||||
Arguments:
|
||||
label: A black-and-white pillow Image object
|
||||
|
||||
kwargs:
|
||||
length: The length of the label (in mm)
|
||||
width: The width of the label (in mm)
|
||||
|
||||
"""
|
||||
|
||||
# Unimplemented (to be implemented by the particular plugin class)
|
||||
...
|
||||
|
||||
|
||||
class APICallMixin:
|
||||
"""
|
||||
Mixin that enables easier API calls for a plugin
|
||||
|
@ -7,12 +7,15 @@ from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
import common.notifications
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
from InvenTree.tasks import offload_task
|
||||
@ -95,7 +98,11 @@ def process_event(plugin_slug, event, *args, **kwargs):
|
||||
|
||||
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
|
||||
|
||||
plugin = registry.plugins[plugin_slug]
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
plugin.process_event(event, *args, **kwargs)
|
||||
|
||||
@ -186,3 +193,46 @@ def after_delete(sender, instance, **kwargs):
|
||||
model=sender.__name__,
|
||||
table=table,
|
||||
)
|
||||
|
||||
|
||||
def print_label(plugin_slug, label_image, label_instance=None, user=None):
|
||||
"""
|
||||
Print label with the provided plugin.
|
||||
|
||||
This task is nominally handled by the background worker.
|
||||
|
||||
If the printing fails (throws an exception) then the user is notified.
|
||||
|
||||
Arguments:
|
||||
plugin_slug: The unique slug (key) of the plugin
|
||||
label_image: A PIL.Image image object to be printed
|
||||
"""
|
||||
|
||||
logger.info(f"Plugin '{plugin_slug}' is printing a label")
|
||||
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
try:
|
||||
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
|
||||
except Exception as e:
|
||||
# Plugin threw an error - notify the user who attempted to print
|
||||
|
||||
ctx = {
|
||||
'name': _('Label printing failed'),
|
||||
'message': str(e),
|
||||
}
|
||||
|
||||
logger.error(f"Label printing failed: Sending notification to user '{user}'")
|
||||
|
||||
# Throw an error against the plugin instance
|
||||
common.notifications.trigger_notifaction(
|
||||
plugin.plugin_config(),
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
context=ctx,
|
||||
delivery_methods=[common.notifications.UIMessageNotification]
|
||||
)
|
||||
|
@ -2,7 +2,8 @@
|
||||
Utility class to enable simpler imports
|
||||
"""
|
||||
|
||||
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
|
||||
from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
|
||||
|
||||
from ..builtin.action.mixins import ActionMixin
|
||||
from ..builtin.barcode.mixins import BarcodeMixin
|
||||
|
||||
@ -10,6 +11,7 @@ __all__ = [
|
||||
'APICallMixin',
|
||||
'AppMixin',
|
||||
'EventMixin',
|
||||
'LabelPrintingMixin',
|
||||
'NavigationMixin',
|
||||
'ScheduleMixin',
|
||||
'SettingsMixin',
|
||||
|
@ -175,23 +175,6 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
|
||||
return super().get_setting_definition(key, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls, key, **kwargs):
|
||||
"""
|
||||
Override filters method to ensure settings are filtered by plugin id
|
||||
"""
|
||||
|
||||
filters = super().get_filters(key, **kwargs)
|
||||
|
||||
plugin = kwargs.get('plugin', None)
|
||||
|
||||
if plugin:
|
||||
if issubclass(plugin.__class__, InvenTreePluginBase):
|
||||
plugin = plugin.plugin_config()
|
||||
filters['plugin'] = plugin
|
||||
|
||||
return filters
|
||||
|
||||
plugin = models.ForeignKey(
|
||||
PluginConfig,
|
||||
related_name='settings',
|
||||
|
@ -10,12 +10,13 @@ import pathlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from typing import OrderedDict
|
||||
from importlib import reload
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.db.utils import OperationalError, ProgrammingError, IntegrityError
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import clear_url_caches
|
||||
from django.contrib import admin
|
||||
@ -282,6 +283,8 @@ class PluginsRegistry:
|
||||
if not settings.PLUGIN_TESTING:
|
||||
raise error # pragma: no cover
|
||||
plugin_db_setting = None
|
||||
except (IntegrityError) as error:
|
||||
logger.error(f"Error initializing plugin: {error}")
|
||||
|
||||
# Always activate if testing
|
||||
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<div class='btn btn-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
|
||||
<div class='btn btn-outline-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
|
||||
<span class='fa fa-bookmark'></span> {% trans "Mark all as read" %}
|
||||
</div>
|
||||
<div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'>
|
||||
|
@ -126,8 +126,12 @@ $("#mark-all").on('click', function() {
|
||||
{
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
updateNotificationTables();
|
||||
}
|
||||
}
|
||||
);
|
||||
updateNotificationTables();
|
||||
});
|
||||
|
||||
loadNotificationTable("#history-table", {
|
||||
|
@ -22,6 +22,7 @@
|
||||
{% include "InvenTree/settings/user_settings.html" %}
|
||||
{% include "InvenTree/settings/user_homepage.html" %}
|
||||
{% include "InvenTree/settings/user_search.html" %}
|
||||
{% include "InvenTree/settings/user_notifications.html" %}
|
||||
{% include "InvenTree/settings/user_labels.html" %}
|
||||
{% include "InvenTree/settings/user_reports.html" %}
|
||||
{% include "InvenTree/settings/user_display.html" %}
|
||||
|
@ -14,6 +14,8 @@
|
||||
{% include "sidebar_item.html" with label='user-home' text=text icon="fa-home" %}
|
||||
{% trans "Search Settings" as text %}
|
||||
{% include "sidebar_item.html" with label='user-search' text=text icon="fa-search" %}
|
||||
{% trans "Notifications" as text %}
|
||||
{% include "sidebar_item.html" with label='user-notifications' text=text icon="fa-bell" %}
|
||||
{% trans "Label Printing" as text %}
|
||||
{% include "sidebar_item.html" with label='user-labels' text=text icon="fa-tag" %}
|
||||
{% trans "Reporting" as text %}
|
||||
|
@ -0,0 +1,20 @@
|
||||
{% extends "panel.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}user-notifications{% endblock label %}
|
||||
|
||||
{% block heading %}{% trans "Notification Settings" %}{% endblock heading %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="NOTIFICATION_SEND_EMAILS" icon='fa-envelope' user_setting=True %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
@ -213,7 +213,7 @@ function createBuildOutput(build_id, options) {
|
||||
success: function(data) {
|
||||
if (data.next) {
|
||||
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
|
||||
} else {
|
||||
} else if (data.latest) {
|
||||
fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
|
||||
}
|
||||
},
|
||||
@ -1025,9 +1025,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
}
|
||||
|
||||
// Store the required quantity in the row data
|
||||
row.required = quantity;
|
||||
// Prevent weird rounding issues
|
||||
row.required = parseFloat(quantity.toFixed(15));
|
||||
|
||||
return quantity;
|
||||
return row.required;
|
||||
}
|
||||
|
||||
function sumAllocations(row) {
|
||||
@ -1043,9 +1044,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
quantity += item.quantity;
|
||||
});
|
||||
|
||||
row.allocated = quantity;
|
||||
row.allocated = parseFloat(quantity.toFixed(15));
|
||||
|
||||
return quantity;
|
||||
return row.allocated;
|
||||
}
|
||||
|
||||
function setupCallbacks() {
|
||||
@ -1642,6 +1643,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
remaining = 0;
|
||||
}
|
||||
|
||||
// Ensure the quantity sent to the form field is correctly formatted
|
||||
remaining = parseFloat(remaining.toFixed(15));
|
||||
|
||||
// We only care about entries which are not yet fully allocated
|
||||
if (remaining > 0) {
|
||||
table_entries += renderBomItemRow(bom_item, remaining);
|
||||
|
@ -14,11 +14,41 @@
|
||||
*/
|
||||
|
||||
/* exported
|
||||
printLabels,
|
||||
printPartLabels,
|
||||
printStockItemLabels,
|
||||
printStockLocationLabels,
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Perform the "print" action.
|
||||
*/
|
||||
function printLabels(url, plugin=null) {
|
||||
|
||||
if (plugin) {
|
||||
// If a plugin is provided, do not redirect the browser.
|
||||
// Instead, perform an API request and display a message
|
||||
|
||||
url = url + `plugin=${plugin}`;
|
||||
|
||||
inventreeGet(url, {}, {
|
||||
success: function(response) {
|
||||
showMessage(
|
||||
'{% trans "Labels sent to printer" %}',
|
||||
{
|
||||
style: 'success',
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function printStockItemLabels(items) {
|
||||
/**
|
||||
* Print stock item labels for the given stock items
|
||||
@ -57,14 +87,17 @@ function printStockItemLabels(items) {
|
||||
response,
|
||||
items,
|
||||
{
|
||||
success: function(pk) {
|
||||
success: function(data) {
|
||||
|
||||
var pk = data.label;
|
||||
|
||||
var href = `/api/label/stock/${pk}/print/?`;
|
||||
|
||||
items.forEach(function(item) {
|
||||
href += `items[]=${item}&`;
|
||||
});
|
||||
|
||||
window.location.href = href;
|
||||
printLabels(href, data.plugin);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -73,6 +106,7 @@ function printStockItemLabels(items) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function printStockLocationLabels(locations) {
|
||||
|
||||
if (locations.length == 0) {
|
||||
@ -107,14 +141,17 @@ function printStockLocationLabels(locations) {
|
||||
response,
|
||||
locations,
|
||||
{
|
||||
success: function(pk) {
|
||||
success: function(data) {
|
||||
|
||||
var pk = data.label;
|
||||
|
||||
var href = `/api/label/location/${pk}/print/?`;
|
||||
|
||||
locations.forEach(function(location) {
|
||||
href += `locations[]=${location}&`;
|
||||
});
|
||||
|
||||
window.location.href = href;
|
||||
printLabels(href, data.plugin);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -162,14 +199,17 @@ function printPartLabels(parts) {
|
||||
response,
|
||||
parts,
|
||||
{
|
||||
success: function(pk) {
|
||||
var url = `/api/label/part/${pk}/print/?`;
|
||||
success: function(data) {
|
||||
|
||||
var pk = data.label;
|
||||
|
||||
var href = `/api/label/part/${pk}/print/?`;
|
||||
|
||||
parts.forEach(function(part) {
|
||||
url += `parts[]=${part}&`;
|
||||
href += `parts[]=${part}&`;
|
||||
});
|
||||
|
||||
window.location.href = url;
|
||||
printLabels(href, data.plugin);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -188,18 +228,51 @@ function selectLabel(labels, items, options={}) {
|
||||
* (via AJAX) from the server.
|
||||
*/
|
||||
|
||||
// If only a single label template is provided,
|
||||
// just run with that!
|
||||
// Array of available plugins for label printing
|
||||
var plugins = [];
|
||||
|
||||
if (labels.length == 1) {
|
||||
if (options.success) {
|
||||
options.success(labels[0].pk);
|
||||
// Request a list of available label printing plugins from the server
|
||||
inventreeGet(
|
||||
`/api/plugin/`,
|
||||
{},
|
||||
{
|
||||
async: false,
|
||||
success: function(response) {
|
||||
response.forEach(function(plugin) {
|
||||
// Look for active plugins which implement the 'labels' mixin class
|
||||
if (plugin.active && plugin.mixins && plugin.mixins.labels) {
|
||||
// This plugin supports label printing
|
||||
plugins.push(plugin);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
var plugin_selection = '';
|
||||
|
||||
if (plugins.length > 0) {
|
||||
plugin_selection =`
|
||||
<div class='form-group'>
|
||||
<label class='control-label requiredField' for='id_plugin'>
|
||||
{% trans "Select Printer" %}
|
||||
</label>
|
||||
<div class='controls'>
|
||||
<select id='id_plugin' class='select form-control' name='plugin'>
|
||||
<option value='' title='{% trans "Export to PDF" %}'>{% trans "Export to PDF" %}</option>
|
||||
`;
|
||||
|
||||
plugins.forEach(function(plugin) {
|
||||
plugin_selection += `<option value='${plugin.key}' title='${plugin.meta.human_name}'>${plugin.name} - <small>${plugin.meta.human_name}</small></option>`;
|
||||
});
|
||||
|
||||
plugin_selection += `
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
var modal = options.modal || '#modal-form';
|
||||
|
||||
var label_list = makeOptionsList(
|
||||
@ -233,14 +306,15 @@ function selectLabel(labels, items, options={}) {
|
||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||
<div class='form-group'>
|
||||
<label class='control-label requiredField' for='id_label'>
|
||||
{% trans "Select Label" %}
|
||||
{% trans "Select Label Template" %}
|
||||
</label>
|
||||
<div class='controls'>
|
||||
<select id='id_label' class='select form-control name='label'>
|
||||
<select id='id_label' class='select form-control' name='label'>
|
||||
${label_list}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${plugin_selection}
|
||||
</form>`;
|
||||
|
||||
openModal({
|
||||
@ -255,14 +329,17 @@ function selectLabel(labels, items, options={}) {
|
||||
|
||||
modalSubmit(modal, function() {
|
||||
|
||||
var label = $(modal).find('#id_label');
|
||||
|
||||
var pk = label.val();
|
||||
var label = $(modal).find('#id_label').val();
|
||||
var plugin = $(modal).find('#id_plugin').val();
|
||||
|
||||
closeModal(modal);
|
||||
|
||||
if (options.success) {
|
||||
options.success(pk);
|
||||
options.success({
|
||||
// Return the selected label template and plugin
|
||||
label: label,
|
||||
plugin: plugin,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -253,7 +253,7 @@ function openNotificationPanel() {
|
||||
{
|
||||
success: function(response) {
|
||||
if (response.length == 0) {
|
||||
html = `<p class='text-muted'>{% trans "No unread notifications" %}</p>`;
|
||||
html = `<p class='text-muted'><em>{% trans "No unread notifications" %}</em><span class='fas fa-check-circle icon-green float-right'></span></p>`;
|
||||
} else {
|
||||
// build up items
|
||||
response.forEach(function(item, index) {
|
||||
|
@ -1770,6 +1770,7 @@ function loadStockTable(table, options) {
|
||||
col = {
|
||||
field: 'location_detail.pathstring',
|
||||
title: '{% trans "Location" %}',
|
||||
sortName: 'location',
|
||||
formatter: function(value, row) {
|
||||
return locationDetail(row);
|
||||
}
|
||||
@ -1912,172 +1913,8 @@ function loadStockTable(table, options) {
|
||||
original: original,
|
||||
showColumns: true,
|
||||
columns: columns,
|
||||
{% if False %}
|
||||
groupByField: options.groupByField || 'part',
|
||||
groupBy: grouping,
|
||||
groupByFormatter: function(field, id, data) {
|
||||
|
||||
var row = data[0];
|
||||
|
||||
if (field == 'part_detail.full_name') {
|
||||
|
||||
var html = imageHoverIcon(row.part_detail.thumbnail);
|
||||
|
||||
html += row.part_detail.full_name;
|
||||
html += ` <i>(${data.length} {% trans "items" %})</i>`;
|
||||
|
||||
html += makePartIcons(row.part_detail);
|
||||
|
||||
return html;
|
||||
} else if (field == 'part_detail.IPN') {
|
||||
var ipn = row.part_detail.IPN;
|
||||
|
||||
if (ipn) {
|
||||
return ipn;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
} else if (field == 'part_detail.description') {
|
||||
return row.part_detail.description;
|
||||
} else if (field == 'packaging') {
|
||||
var packaging = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
var pkg = item.packaging;
|
||||
|
||||
if (!pkg) {
|
||||
pkg = '-';
|
||||
}
|
||||
|
||||
if (!packaging.includes(pkg)) {
|
||||
packaging.push(pkg);
|
||||
}
|
||||
});
|
||||
|
||||
if (packaging.length > 1) {
|
||||
return "...";
|
||||
} else if (packaging.length == 1) {
|
||||
return packaging[0];
|
||||
} else {
|
||||
return "-";
|
||||
}
|
||||
} else if (field == 'quantity') {
|
||||
var stock = 0;
|
||||
var items = 0;
|
||||
|
||||
data.forEach(function(item) {
|
||||
stock += parseFloat(item.quantity);
|
||||
items += 1;
|
||||
});
|
||||
|
||||
stock = +stock.toFixed(5);
|
||||
|
||||
return `${stock} (${items} {% trans "items" %})`;
|
||||
} else if (field == 'status') {
|
||||
var statii = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
var status = String(item.status);
|
||||
|
||||
if (!status || status == '') {
|
||||
status = '-';
|
||||
}
|
||||
|
||||
if (!statii.includes(status)) {
|
||||
statii.push(status);
|
||||
}
|
||||
});
|
||||
|
||||
// Multiple status codes
|
||||
if (statii.length > 1) {
|
||||
return "...";
|
||||
} else if (statii.length == 1) {
|
||||
return stockStatusDisplay(statii[0]);
|
||||
} else {
|
||||
return "-";
|
||||
}
|
||||
} else if (field == 'batch') {
|
||||
var batches = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
var batch = item.batch;
|
||||
|
||||
if (!batch || batch == '') {
|
||||
batch = '-';
|
||||
}
|
||||
|
||||
if (!batches.includes(batch)) {
|
||||
batches.push(batch);
|
||||
}
|
||||
});
|
||||
|
||||
if (batches.length > 1) {
|
||||
return "" + batches.length + " {% trans 'batches' %}";
|
||||
} else if (batches.length == 1) {
|
||||
if (batches[0]) {
|
||||
return batches[0];
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
} else if (field == 'location_detail.pathstring') {
|
||||
/* Determine how many locations */
|
||||
var locations = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
|
||||
var detail = locationDetail(item);
|
||||
|
||||
if (!locations.includes(detail)) {
|
||||
locations.push(detail);
|
||||
}
|
||||
});
|
||||
|
||||
if (locations.length == 1) {
|
||||
// Single location, easy!
|
||||
return locations[0];
|
||||
} else if (locations.length > 1) {
|
||||
return "In " + locations.length + " {% trans 'locations' %}";
|
||||
} else {
|
||||
return "<i>{% trans 'Undefined location' %}</i>";
|
||||
}
|
||||
} else if (field == 'notes') {
|
||||
var notes = [];
|
||||
|
||||
data.forEach(function(item) {
|
||||
var note = item.notes;
|
||||
|
||||
if (!note || note == '') {
|
||||
note = '-';
|
||||
}
|
||||
|
||||
if (!notes.includes(note)) {
|
||||
notes.push(note);
|
||||
}
|
||||
});
|
||||
|
||||
if (notes.length > 1) {
|
||||
return '...';
|
||||
} else if (notes.length == 1) {
|
||||
return notes[0] || '-';
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
/*
|
||||
if (options.buttons) {
|
||||
linkButtonsToSelection(table, options.buttons);
|
||||
}
|
||||
*/
|
||||
|
||||
var buttons = [
|
||||
'#stock-print-options',
|
||||
'#stock-options',
|
||||
@ -2092,7 +1929,6 @@ function loadStockTable(table, options) {
|
||||
buttons,
|
||||
);
|
||||
|
||||
|
||||
function stockAdjustment(action) {
|
||||
var items = $(table).bootstrapTable('getSelections');
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Base python requirements for docker containers
|
||||
|
||||
# Basic package requirements
|
||||
setuptools>=57.4.0
|
||||
setuptools>=57.4.0,<=60.1.0
|
||||
wheel>=0.37.0
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user