mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26:44 +00:00
Merge branch 'inventree:master' into matmair/issue2788
This commit is contained in:
commit
ed60666695
@ -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)
|
||||
|
@ -1019,3 +1019,32 @@ a {
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Quicksearch Panel */
|
||||
|
||||
.search-result-panel {
|
||||
max-width: 800px;
|
||||
width: 75%
|
||||
}
|
||||
|
||||
.search-result-group {
|
||||
padding: 5px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-result-group-buttons > button{
|
||||
padding: 2px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.search-result-entry {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 3px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
}
|
@ -128,81 +128,6 @@ function inventreeDocReady() {
|
||||
attachClipboard('.clip-btn', 'modal-about');
|
||||
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text');
|
||||
|
||||
// Add autocomplete to the search-bar
|
||||
if ($('#search-bar').exists()) {
|
||||
$('#search-bar').autocomplete({
|
||||
source: function(request, response) {
|
||||
|
||||
var params = {
|
||||
search: request.term,
|
||||
limit: user_settings.SEARCH_PREVIEW_RESULTS,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
||||
// Limit to active parts
|
||||
params.active = true;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/api/part/',
|
||||
data: params,
|
||||
success: function(data) {
|
||||
|
||||
var transformed = $.map(data.results, function(el) {
|
||||
return {
|
||||
label: el.full_name,
|
||||
id: el.pk,
|
||||
thumbnail: el.thumbnail,
|
||||
data: el,
|
||||
};
|
||||
});
|
||||
response(transformed);
|
||||
},
|
||||
error: function() {
|
||||
response([]);
|
||||
}
|
||||
});
|
||||
},
|
||||
create: function() {
|
||||
$(this).data('ui-autocomplete')._renderItem = function(ul, item) {
|
||||
|
||||
var html = `
|
||||
<div class='search-autocomplete-item' title='${item.data.description}'>
|
||||
<a href='/part/${item.id}/'>
|
||||
<span style='padding-right: 10px;'><img class='hover-img-thumb' src='${item.thumbnail || "/static/img/blank_image.png"}'> ${item.label}</span>
|
||||
</a>
|
||||
<span class='flex' style='flex-grow: 1;'></span>
|
||||
`;
|
||||
|
||||
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
|
||||
html += partStockLabel(
|
||||
item.data,
|
||||
{
|
||||
classes: 'badge-right',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return $('<li>').append(html).appendTo(ul);
|
||||
};
|
||||
},
|
||||
select: function( event, ui ) {
|
||||
window.location = '/part/' + ui.item.id + '/';
|
||||
},
|
||||
minLength: 2,
|
||||
classes: {
|
||||
'ui-autocomplete': 'dropdown-menu search-menu',
|
||||
},
|
||||
position: {
|
||||
my : "right top",
|
||||
at: "right bottom"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate brand-icons
|
||||
$('.brand-icon').each(function(i, obj) {
|
||||
loadBrandIcon($(this), $(this).attr('brand_name'));
|
||||
@ -231,8 +156,13 @@ function inventreeDocReady() {
|
||||
stopNotificationWatcher();
|
||||
});
|
||||
|
||||
$('#offcanvasRight').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
|
||||
$('#offcanvasRight').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
|
||||
// Calbacks for search panel
|
||||
$('#offcanvas-search').on('shown.bs.offcanvas', openSearchPanel);
|
||||
$('#offcanvas-search').on('hidden.bs.offcanvas', closeSearchPanel);
|
||||
|
||||
// Callbacks for notifications panel
|
||||
$('#offcanvas-notification').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
|
||||
$('#offcanvas-notification').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
|
||||
}
|
||||
|
||||
|
||||
|
@ -72,7 +72,7 @@ class ViewTests(TestCase):
|
||||
"""
|
||||
|
||||
# Change this number as more javascript files are added to the index page
|
||||
N_SCRIPT_FILES = 37
|
||||
N_SCRIPT_FILES = 38
|
||||
|
||||
content = self.get_index_page()
|
||||
|
||||
|
@ -130,6 +130,7 @@ translated_javascript_urls = [
|
||||
url(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
|
||||
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
|
||||
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
|
||||
url(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
|
||||
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
||||
url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
|
||||
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
|
||||
|
@ -12,11 +12,24 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 32
|
||||
INVENTREE_API_VERSION = 36
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v36 -> 2022-04-03
|
||||
- Adds ability to filter part list endpoint by unallocated_stock argument
|
||||
|
||||
v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797
|
||||
- Adds stock allocation information to the Part API
|
||||
- Adds calculated field for "unallocated_quantity"
|
||||
|
||||
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
|
||||
@ -190,7 +203,7 @@ def isInvenTreeUpToDate():
|
||||
and stores it to the database as INVENTREE_LATEST_VERSION
|
||||
"""
|
||||
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', None)
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
if not latest:
|
||||
|
@ -1,5 +1,7 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% block pre_form_content %}
|
||||
Are you sure you want to delete this build?
|
||||
|
||||
{% trans "Are you sure you want to delete this build?" %}
|
||||
|
||||
{% endblock %}
|
@ -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,20 @@ 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_ENABLE': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
"LABEL_INLINE": {
|
||||
'name': _('Inline label display'),
|
||||
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
|
||||
@ -1214,20 +1268,62 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_RESULTS': {
|
||||
'name': _('Search Preview Results'),
|
||||
'description': _('Number of results to show in search preview window'),
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_SHOW_STOCK_LEVELS': {
|
||||
'name': _('Search Show Stock'),
|
||||
'description': _('Display stock levels in search preview window'),
|
||||
'SEARCH_PREVIEW_SHOW_PARTS': {
|
||||
'name': _('Search Parts'),
|
||||
'description': _('Display parts in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_CATEGORIES': {
|
||||
'name': _('Search Categories'),
|
||||
'description': _('Display part categories in search preview window'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_STOCK': {
|
||||
'name': _('Search Stock'),
|
||||
'description': _('Display stock items in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_LOCATIONS': {
|
||||
'name': _('Search Locations'),
|
||||
'description': _('Display stock locations in search preview window'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_COMPANIES': {
|
||||
'name': _('Search Companies'),
|
||||
'description': _('Display companies in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
|
||||
'name': _('Search Purchase Orders'),
|
||||
'description': _('Display purchase orders in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_SALES_ORDERS': {
|
||||
'name': _('Search Sales Orders'),
|
||||
'description': _('Display sales orders in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_RESULTS': {
|
||||
'name': _('Search Preview Results'),
|
||||
'description': _('Number of results to show in each section of the search preview window'),
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_HIDE_INACTIVE_PARTS': {
|
||||
'name': _("Hide Inactive Parts"),
|
||||
'description': _('Hide inactive parts in search preview window'),
|
||||
@ -1305,16 +1401,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,6 +197,7 @@ 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
|
||||
if delivery_methods is None:
|
||||
delivery_methods = inheritors(NotificationMethod)
|
||||
|
||||
for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]:
|
||||
|
@ -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 {
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -798,6 +798,20 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
# unallocated_stock filter
|
||||
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
|
||||
|
||||
def filter_unallocated_stock(self, queryset, name, value):
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
queryset = queryset.filter(Q(unallocated_stock__gt=0))
|
||||
else:
|
||||
queryset = queryset.filter(Q(unallocated_stock__lte=0))
|
||||
|
||||
return queryset
|
||||
|
||||
is_template = rest_filters.BooleanFilter()
|
||||
|
||||
assembly = rest_filters.BooleanFilter()
|
||||
@ -1334,6 +1348,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'unallocated_stock',
|
||||
'category',
|
||||
]
|
||||
|
||||
|
@ -38,3 +38,11 @@
|
||||
part: 1
|
||||
sub_part: 5
|
||||
quantity: 3
|
||||
|
||||
# Make "Assembly" from "Bob"
|
||||
- model: part.bomitem
|
||||
pk: 6
|
||||
fields:
|
||||
part: 101
|
||||
sub_part: 100
|
||||
quantity: 10
|
||||
|
@ -108,6 +108,18 @@
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 101
|
||||
fields:
|
||||
name: 'Assembly'
|
||||
description: 'A high level assembly'
|
||||
salable: true
|
||||
active: True
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# A 'template' part
|
||||
- model: part.part
|
||||
pk: 10000
|
||||
|
@ -1345,7 +1345,8 @@ class Part(MPTTModel):
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
|
||||
|
||||
pending = kwargs.get('pending', None)
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
|
||||
if pending is True:
|
||||
# Look only for 'open' orders which have not shipped
|
||||
@ -1433,7 +1434,7 @@ class Part(MPTTModel):
|
||||
- If this part is a "template" (variants exist) then these are counted too
|
||||
"""
|
||||
|
||||
return self.get_stock_count()
|
||||
return self.get_stock_count(include_variants=True)
|
||||
|
||||
def get_bom_item_filter(self, include_inherited=True):
|
||||
"""
|
||||
|
@ -7,7 +7,7 @@ from decimal import Decimal
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models import ExpressionWrapper, F, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@ -24,7 +24,10 @@ from InvenTree.serializers import (DataFileUploadSerializer,
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from InvenTree.status_codes import (BuildStatus,
|
||||
PurchaseOrderStatus,
|
||||
SalesOrderStatus)
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, BomItemSubstitute,
|
||||
@ -363,6 +366,51 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
),
|
||||
)
|
||||
|
||||
"""
|
||||
Annotate with the number of stock items allocated to sales orders.
|
||||
This annotation is modeled on Part.sales_order_allocations() method:
|
||||
|
||||
- Only look for "open" orders
|
||||
- Stock items have not been "shipped"
|
||||
"""
|
||||
so_allocation_filter = Q(
|
||||
line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order
|
||||
shipment__shipment_date=None, # Allocated item has *not* been shipped out
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
allocated_to_sales_orders=Coalesce(
|
||||
SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
"""
|
||||
Annotate with the number of stock items allocated to build orders.
|
||||
This annotation is modeled on Part.build_order_allocations() method
|
||||
"""
|
||||
bo_allocation_filter = Q(
|
||||
build__status__in=BuildStatus.ACTIVE_CODES,
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
allocated_to_build_orders=Coalesce(
|
||||
SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate with the total 'available stock' quantity
|
||||
# This is the current stock, minus any allocations
|
||||
queryset = queryset.annotate(
|
||||
unallocated_stock=ExpressionWrapper(
|
||||
F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
@ -376,9 +424,12 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
# Calculated fields
|
||||
allocated_to_build_orders = serializers.FloatField(read_only=True)
|
||||
allocated_to_sales_orders = serializers.FloatField(read_only=True)
|
||||
unallocated_stock = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
|
||||
@ -399,7 +450,8 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
partial = True
|
||||
fields = [
|
||||
'active',
|
||||
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'assembly',
|
||||
'category',
|
||||
'category_detail',
|
||||
@ -430,6 +482,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'suppliers',
|
||||
'thumbnail',
|
||||
'trackable',
|
||||
'unallocated_stock',
|
||||
'units',
|
||||
'variant_of',
|
||||
'virtual',
|
||||
|
@ -5,7 +5,6 @@ import logging
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
import common.notifications
|
||||
|
@ -37,13 +37,17 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if barcodes %}
|
||||
{% if barcodes or labels_enabled %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
{% if barcodes %}
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -424,9 +428,11 @@
|
||||
);
|
||||
});
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function adjustPartStock(action) {
|
||||
inventreeGet(
|
||||
|
@ -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
|
||||
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,7 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from part.models import BomItem, BomItemSubstitute
|
||||
@ -17,6 +17,9 @@ from stock.models import StockItem, StockLocation
|
||||
from company.models import Company
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
import build.models
|
||||
import order.models
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -247,7 +250,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
data = {'cascade': True}
|
||||
response = self.client.get(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 13)
|
||||
self.assertEqual(len(response.data), Part.objects.count())
|
||||
|
||||
def test_get_parts_by_cat(self):
|
||||
url = reverse('api-part-list')
|
||||
@ -815,6 +818,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
'build',
|
||||
'location',
|
||||
'stock',
|
||||
'sales_order',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -826,6 +833,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Ensure the part "variant" tree is correctly structured
|
||||
Part.objects.rebuild()
|
||||
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
@ -880,6 +890,153 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['in_stock'], 1100)
|
||||
self.assertEqual(data['stock_item_count'], 105)
|
||||
|
||||
def test_allocation_annotations(self):
|
||||
"""
|
||||
Tests for query annotations which add allocation information.
|
||||
Ref: https://github.com/inventree/InvenTree/pull/2797
|
||||
"""
|
||||
|
||||
# We are looking at Part ID 100 ("Bob")
|
||||
url = reverse('api-part-detail', kwargs={'pk': 100})
|
||||
|
||||
part = Part.objects.get(pk=100)
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
# Check that the expected annotated fields exist in the data
|
||||
data = response.data
|
||||
self.assertEqual(data['allocated_to_build_orders'], 0)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 0)
|
||||
|
||||
# The unallocated stock count should equal the 'in stock' coutn
|
||||
in_stock = data['in_stock']
|
||||
self.assertEqual(in_stock, 126)
|
||||
self.assertEqual(data['unallocated_stock'], in_stock)
|
||||
|
||||
# Check that model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 0)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 0)
|
||||
self.assertEqual(part.total_stock, in_stock)
|
||||
self.assertEqual(part.available_stock, in_stock)
|
||||
|
||||
# Now, let's create a sales order, and allocate some stock
|
||||
so = order.models.SalesOrder.objects.create(
|
||||
reference='001',
|
||||
customer=Company.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# We wish to send 50 units of "Bob" against this sales order
|
||||
line = order.models.SalesOrderLineItem.objects.create(
|
||||
quantity=50,
|
||||
order=so,
|
||||
part=part,
|
||||
)
|
||||
|
||||
# Create a shipment against the order
|
||||
shipment_1 = order.models.SalesOrderShipment.objects.create(
|
||||
order=so,
|
||||
reference='001',
|
||||
)
|
||||
|
||||
shipment_2 = order.models.SalesOrderShipment.objects.create(
|
||||
order=so,
|
||||
reference='002',
|
||||
)
|
||||
|
||||
# Allocate stock items to this order, against multiple shipments
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_1,
|
||||
item=StockItem.objects.get(pk=1007),
|
||||
quantity=17
|
||||
)
|
||||
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_1,
|
||||
item=StockItem.objects.get(pk=1008),
|
||||
quantity=18
|
||||
)
|
||||
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line,
|
||||
shipment=shipment_2,
|
||||
item=StockItem.objects.get(pk=1006),
|
||||
quantity=15,
|
||||
)
|
||||
|
||||
# Submit the API request again - should show us the sales order allocation
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 50)
|
||||
self.assertEqual(data['in_stock'], 126)
|
||||
self.assertEqual(data['unallocated_stock'], 76)
|
||||
|
||||
# Now, "ship" the first shipment (so the stock is not 'in stock' any more)
|
||||
shipment_1.complete_shipment(None)
|
||||
|
||||
# Refresh the API data
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 0)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 76)
|
||||
|
||||
# Next, we create a build order and allocate stock against it
|
||||
bo = build.models.Build.objects.create(
|
||||
part=Part.objects.get(pk=101),
|
||||
quantity=10,
|
||||
title='Making some assemblies',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
)
|
||||
|
||||
bom_item = BomItem.objects.get(pk=6)
|
||||
|
||||
# Allocate multiple stock items against this build order
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
stock_item=StockItem.objects.get(pk=1000),
|
||||
quantity=10,
|
||||
)
|
||||
|
||||
# Request data once more
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 10)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 66)
|
||||
|
||||
# Again, check that the direct model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 10)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 15)
|
||||
self.assertEqual(part.total_stock, 91)
|
||||
self.assertEqual(part.available_stock, 66)
|
||||
|
||||
# Allocate further stock against the build
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
stock_item=StockItem.objects.get(pk=1001),
|
||||
quantity=10,
|
||||
)
|
||||
|
||||
# Request data once more
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['allocated_to_build_orders'], 20)
|
||||
self.assertEqual(data['allocated_to_sales_orders'], 15)
|
||||
self.assertEqual(data['in_stock'], 91)
|
||||
self.assertEqual(data['unallocated_stock'], 56)
|
||||
|
||||
# Again, check that the direct model functions return the same values
|
||||
self.assertEqual(part.build_order_allocation_count(), 20)
|
||||
self.assertEqual(part.sales_order_allocation_count(), 15)
|
||||
self.assertEqual(part.total_stock, 91)
|
||||
self.assertEqual(part.available_stock, 56)
|
||||
|
||||
|
||||
class BomItemTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@ -46,7 +46,7 @@ class BomItemTest(TestCase):
|
||||
# TODO: Tests for multi-level BOMs
|
||||
|
||||
def test_used_in(self):
|
||||
self.assertEqual(self.bob.used_in_count, 0)
|
||||
self.assertEqual(self.bob.used_in_count, 1)
|
||||
self.assertEqual(self.orphan.used_in_count, 1)
|
||||
|
||||
def test_self_reference(self):
|
||||
|
@ -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):
|
||||
|
@ -251,3 +251,104 @@
|
||||
rght: 0
|
||||
expiry_date: "1990-10-10"
|
||||
status: 70
|
||||
|
||||
# Multiple stock items for "Bob" (PK 100)
|
||||
- model: stock.stockitem
|
||||
pk: 1000
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 10
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1001
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 11
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1002
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 12
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1003
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 13
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1004
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 14
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1005
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 15
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1006
|
||||
fields:
|
||||
part: 100
|
||||
location: 1
|
||||
quantity: 16
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1007
|
||||
fields:
|
||||
part: 100
|
||||
location: 7
|
||||
quantity: 17
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1008
|
||||
fields:
|
||||
part: 100
|
||||
location: 7
|
||||
quantity: 18
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
@ -49,15 +49,20 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Document / label menu -->
|
||||
{% if test_report_enabled or labels_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
{% if test_report_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stock adjustment menu -->
|
||||
{% if user_owns_item %}
|
||||
{% if roles.stock.change and not item.is_building %}
|
||||
|
@ -34,7 +34,9 @@
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -181,6 +183,7 @@
|
||||
<div id='sublocation-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
<!-- Printing actions menu -->
|
||||
{% if labels_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='location-print-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle="dropdown" title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
@ -189,6 +192,7 @@
|
||||
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "filter_list.html" with id="location" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -222,6 +226,15 @@
|
||||
]
|
||||
);
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
|
||||
var locs = [{{ location.pk }}];
|
||||
|
||||
printStockLocationLabels(locs);
|
||||
|
||||
});
|
||||
|
||||
$('#multi-location-print-label').click(function() {
|
||||
|
||||
var selections = $('#sublocation-table').bootstrapTable('getSelections');
|
||||
@ -234,6 +247,7 @@
|
||||
|
||||
printStockLocationLabels(locations);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if location %}
|
||||
$("#barcode-check-in").click(function() {
|
||||
@ -298,14 +312,6 @@
|
||||
adjustLocationStock('move');
|
||||
});
|
||||
|
||||
$('#print-label').click(function() {
|
||||
|
||||
var locs = [{{ location.pk }}];
|
||||
|
||||
printStockLocationLabels(locs);
|
||||
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#show-qr-code').click(function() {
|
||||
|
@ -104,7 +104,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
response = self.get_stock()
|
||||
|
||||
self.assertEqual(len(response), 20)
|
||||
self.assertEqual(len(response), 29)
|
||||
|
||||
def test_filter_by_part(self):
|
||||
"""
|
||||
@ -113,7 +113,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
response = self.get_stock(part=25)
|
||||
|
||||
self.assertEqual(len(response), 8)
|
||||
self.assertEqual(len(response), 17)
|
||||
|
||||
response = self.get_stock(part=10004)
|
||||
|
||||
@ -136,13 +136,13 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(location=1, cascade=0)
|
||||
self.assertEqual(len(response), 0)
|
||||
self.assertEqual(len(response), 7)
|
||||
|
||||
response = self.get_stock(location=1, cascade=1)
|
||||
self.assertEqual(len(response), 2)
|
||||
self.assertEqual(len(response), 9)
|
||||
|
||||
response = self.get_stock(location=7)
|
||||
self.assertEqual(len(response), 16)
|
||||
self.assertEqual(len(response), 18)
|
||||
|
||||
def test_filter_by_depleted(self):
|
||||
"""
|
||||
@ -153,7 +153,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(depleted=0)
|
||||
self.assertEqual(len(response), 19)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
def test_filter_by_in_stock(self):
|
||||
"""
|
||||
@ -161,7 +161,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
response = self.get_stock(in_stock=1)
|
||||
self.assertEqual(len(response), 17)
|
||||
self.assertEqual(len(response), 26)
|
||||
|
||||
response = self.get_stock(in_stock=0)
|
||||
self.assertEqual(len(response), 3)
|
||||
@ -172,7 +172,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
codes = {
|
||||
StockStatus.OK: 18,
|
||||
StockStatus.OK: 27,
|
||||
StockStatus.DESTROYED: 1,
|
||||
StockStatus.LOST: 1,
|
||||
StockStatus.DAMAGED: 0,
|
||||
@ -205,7 +205,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertIsNotNone(item['serial'])
|
||||
|
||||
response = self.get_stock(serialized=0)
|
||||
self.assertEqual(len(response), 8)
|
||||
self.assertEqual(len(response), 17)
|
||||
|
||||
for item in response:
|
||||
self.assertIsNone(item['serial'])
|
||||
@ -217,7 +217,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
# First, we can assume that the 'stock expiry' feature is disabled
|
||||
response = self.get_stock(expired=1)
|
||||
self.assertEqual(len(response), 20)
|
||||
self.assertEqual(len(response), 29)
|
||||
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
@ -232,7 +232,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertTrue(item['expired'])
|
||||
|
||||
response = self.get_stock(expired=0)
|
||||
self.assertEqual(len(response), 19)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
for item in response:
|
||||
self.assertFalse(item['expired'])
|
||||
@ -249,7 +249,7 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertEqual(len(response), 4)
|
||||
|
||||
response = self.get_stock(expired=0)
|
||||
self.assertEqual(len(response), 16)
|
||||
self.assertEqual(len(response), 25)
|
||||
|
||||
def test_paginate(self):
|
||||
"""
|
||||
@ -290,7 +290,8 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
dataset = self.export_data({})
|
||||
|
||||
self.assertEqual(len(dataset), 20)
|
||||
# Check that *all* stock item objects have been exported
|
||||
self.assertEqual(len(dataset), StockItem.objects.count())
|
||||
|
||||
# Expected headers
|
||||
headers = [
|
||||
@ -308,11 +309,11 @@ class StockItemListTest(StockAPITestCase):
|
||||
# Now, add a filter to the results
|
||||
dataset = self.export_data({'location': 1})
|
||||
|
||||
self.assertEqual(len(dataset), 2)
|
||||
self.assertEqual(len(dataset), 9)
|
||||
|
||||
dataset = self.export_data({'part': 25})
|
||||
|
||||
self.assertEqual(len(dataset), 8)
|
||||
self.assertEqual(len(dataset), 17)
|
||||
|
||||
|
||||
class StockItemTest(StockAPITestCase):
|
||||
|
@ -167,8 +167,8 @@ class StockTest(TestCase):
|
||||
self.assertFalse(self.drawer2.has_items())
|
||||
|
||||
# Drawer 3 should have three stock items
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 16)
|
||||
self.assertEqual(self.drawer3.item_count, 16)
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 18)
|
||||
self.assertEqual(self.drawer3.item_count, 18)
|
||||
|
||||
def test_stock_count(self):
|
||||
part = Part.objects.get(pk=1)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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 %}
|
||||
|
@ -14,6 +14,7 @@
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="LABEL_ENABLE" icon='fa-toggle-on' user_setting=True %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LABEL_INLINE" icon='fa-tag' user_setting=True %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -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 %}
|
@ -14,8 +14,16 @@
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_PARTS" user_setting=True icon='fa-shapes' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_CATEGORIES" user_setting=True icon='fa-sitemap' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_STOCK" user_setting=True icon='fa-boxes' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_LOCATIONS" user_setting=True icon='fa-sitemap' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_COMPANIES" user_setting=True icon='fa-building' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS" user_setting=True icon='fa-shopping-cart' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %}
|
||||
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
|
||||
|
||||
{% include "InvenTree/settings/setting.html" with key="SEARCH_HIDE_INACTIVE_PARTS" user_setting=True icon='fa-eye-slash' %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -6,6 +6,7 @@
|
||||
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
||||
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
||||
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
|
||||
{% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
|
||||
{% inventree_demo_mode as demo_mode %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
@ -126,9 +127,11 @@
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% include 'modals.html' %}
|
||||
{% include 'about.html' %}
|
||||
{% include "notifications.html" %}
|
||||
{% include "search.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
@ -185,6 +188,7 @@
|
||||
<script type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
||||
|
@ -4,6 +4,7 @@
|
||||
editSetting,
|
||||
user_settings,
|
||||
global_settings,
|
||||
plugins_enabled,
|
||||
*/
|
||||
|
||||
{% user_settings request.user as USER_SETTINGS %}
|
||||
@ -20,6 +21,13 @@ const global_settings = {
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
{% plugins_enabled as p_en %}
|
||||
{% if p_en %}
|
||||
const plugins_enabled = true;
|
||||
{% else %}
|
||||
const plugins_enabled = false;
|
||||
{% endif %}
|
||||
|
||||
/*
|
||||
* Edit a setting value
|
||||
*/
|
||||
|
@ -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);
|
||||
@ -1742,7 +1746,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
required: true,
|
||||
render_part_detail: true,
|
||||
render_location_detail: true,
|
||||
render_stock_id: false,
|
||||
render_pk: false,
|
||||
auto_fill: true,
|
||||
auto_fill_filters: auto_fill_filters,
|
||||
onSelect: function(data, field, opts) {
|
||||
|
@ -10,15 +10,46 @@
|
||||
modalSetTitle,
|
||||
modalSubmit,
|
||||
openModal,
|
||||
plugins_enabled,
|
||||
showAlertDialog,
|
||||
*/
|
||||
|
||||
/* 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 +88,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 +107,7 @@ function printStockItemLabels(items) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function printStockLocationLabels(locations) {
|
||||
|
||||
if (locations.length == 0) {
|
||||
@ -107,14 +142,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 +200,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,17 +229,52 @@ 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
|
||||
if (plugins_enabled) {
|
||||
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_enabled && 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';
|
||||
|
||||
@ -233,14 +309,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 +332,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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -10,7 +10,9 @@
|
||||
renderCompany,
|
||||
renderManufacturerPart,
|
||||
renderOwner,
|
||||
renderPart,
|
||||
renderPartCategory,
|
||||
renderStockItem,
|
||||
renderStockLocation,
|
||||
renderSupplierPart,
|
||||
*/
|
||||
@ -29,15 +31,33 @@
|
||||
*/
|
||||
|
||||
|
||||
// Should the ID be rendered for this string
|
||||
function renderId(title, pk, parameters={}) {
|
||||
|
||||
// Default = true
|
||||
var render = true;
|
||||
|
||||
if ('render_pk' in parameters) {
|
||||
render = parameters['render_pk'];
|
||||
}
|
||||
|
||||
if (render) {
|
||||
return `<span class='float-right'><small>${title}: ${pk}</small></span>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Renderer for "Company" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderCompany(name, data, parameters, options) {
|
||||
function renderCompany(name, data, parameters={}, options={}) {
|
||||
|
||||
var html = select2Thumbnail(data.image);
|
||||
|
||||
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
|
||||
|
||||
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
|
||||
html += renderId('{% trans "Company ID" %}', data.pk, parameters);
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -45,7 +65,7 @@ function renderCompany(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "StockItem" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderStockItem(name, data, parameters, options) {
|
||||
function renderStockItem(name, data, parameters={}, options={}) {
|
||||
|
||||
var image = blankImage();
|
||||
|
||||
@ -65,18 +85,6 @@ function renderStockItem(name, data, parameters, options) {
|
||||
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
||||
}
|
||||
|
||||
var render_stock_id = true;
|
||||
|
||||
if ('render_stock_id' in parameters) {
|
||||
render_stock_id = parameters['render_stock_id'];
|
||||
}
|
||||
|
||||
var stock_id = '';
|
||||
|
||||
if (render_stock_id) {
|
||||
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||
}
|
||||
|
||||
var render_location_detail = false;
|
||||
|
||||
if ('render_location_detail' in parameters) {
|
||||
@ -86,7 +94,7 @@ function renderStockItem(name, data, parameters, options) {
|
||||
var location_detail = '';
|
||||
|
||||
if (render_location_detail && data.location_detail) {
|
||||
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
|
||||
location_detail = ` <small>- (<em>${data.location_detail.name}</em>)</small>`;
|
||||
}
|
||||
|
||||
var stock_detail = '';
|
||||
@ -101,7 +109,10 @@ function renderStockItem(name, data, parameters, options) {
|
||||
|
||||
var html = `
|
||||
<span>
|
||||
${part_detail}${stock_detail}${location_detail}${stock_id}
|
||||
${part_detail}
|
||||
${stock_detail}
|
||||
${location_detail}
|
||||
${renderId('{% trans "Stock ID" %}', data.pk, parameters)}
|
||||
</span>
|
||||
`;
|
||||
|
||||
@ -111,7 +122,7 @@ function renderStockItem(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "StockLocation" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderStockLocation(name, data, parameters, options) {
|
||||
function renderStockLocation(name, data, parameters={}, options={}) {
|
||||
|
||||
var level = '- '.repeat(data.level);
|
||||
|
||||
@ -133,7 +144,7 @@ function renderStockLocation(name, data, parameters, options) {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderBuild(name, data, parameters, options) {
|
||||
function renderBuild(name, data, parameters={}, options={}) {
|
||||
|
||||
var image = null;
|
||||
|
||||
@ -154,7 +165,7 @@ function renderBuild(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "Part" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPart(name, data, parameters, options) {
|
||||
function renderPart(name, data, parameters={}, options={}) {
|
||||
|
||||
var html = select2Thumbnail(data.image);
|
||||
|
||||
@ -164,13 +175,14 @@ function renderPart(name, data, parameters, options) {
|
||||
html += ` - <i><small>${data.description}</small></i>`;
|
||||
}
|
||||
|
||||
var extra = '';
|
||||
var stock_data = '';
|
||||
|
||||
// Display available part quantity
|
||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||
extra += partStockLabel(data);
|
||||
stock_data = partStockLabel(data);
|
||||
}
|
||||
|
||||
var extra = '';
|
||||
|
||||
if (!data.active) {
|
||||
extra += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Inactive" %}</span>`;
|
||||
}
|
||||
@ -178,8 +190,9 @@ function renderPart(name, data, parameters, options) {
|
||||
html += `
|
||||
<span class='float-right'>
|
||||
<small>
|
||||
${stock_data}
|
||||
${extra}
|
||||
{% trans "Part ID" %}: ${data.pk}
|
||||
${renderId('{% trans "Part ID" $}', data.pk, parameters)}
|
||||
</small>
|
||||
</span>`;
|
||||
|
||||
@ -188,7 +201,7 @@ function renderPart(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "User" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderUser(name, data, parameters, options) {
|
||||
function renderUser(name, data, parameters={}, options={}) {
|
||||
|
||||
var html = `<span>${data.username}</span>`;
|
||||
|
||||
@ -202,7 +215,7 @@ function renderUser(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "Owner" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderOwner(name, data, parameters, options) {
|
||||
function renderOwner(name, data, parameters={}, options={}) {
|
||||
|
||||
var html = `<span>${data.name}</span>`;
|
||||
|
||||
@ -223,15 +236,13 @@ function renderOwner(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "PurchaseOrder" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPurchaseOrder(name, data, parameters, options) {
|
||||
var html = '';
|
||||
function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
||||
|
||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
||||
var html = `<span>${prefix}${data.reference}</span>`;
|
||||
|
||||
var thumbnail = null;
|
||||
|
||||
html += `<span>${prefix}${data.reference}</span>`;
|
||||
|
||||
if (data.supplier_detail) {
|
||||
thumbnail = data.supplier_detail.thumbnail || data.supplier_detail.image;
|
||||
|
||||
@ -243,13 +254,7 @@ function renderPurchaseOrder(name, data, parameters, options) {
|
||||
html += ` - <em>${data.description}</em>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<span class='float-right'>
|
||||
<small>
|
||||
{% trans "Order ID" %}: ${data.pk}
|
||||
</small>
|
||||
</span>
|
||||
`;
|
||||
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -257,19 +262,25 @@ function renderPurchaseOrder(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "SalesOrder" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSalesOrder(name, data, parameters, options) {
|
||||
var html = `<span>${data.reference}</span>`;
|
||||
function renderSalesOrder(name, data, parameters={}, options={}) {
|
||||
|
||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
var html = `<span>${prefix}${data.reference}</span>`;
|
||||
|
||||
var thumbnail = null;
|
||||
|
||||
if (data.customer_detail) {
|
||||
thumbnail = data.customer_detail.thumbnail || data.customer_detail.image;
|
||||
|
||||
html += ' - ' + select2Thumbnail(thumbnail);
|
||||
html += `<span>${data.customer_detail.name}</span>`;
|
||||
}
|
||||
|
||||
if (data.description) {
|
||||
html += ` - <em>${data.description}</em>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<span class='float-right'>
|
||||
<small>
|
||||
{% trans "Order ID" %}: ${data.pk}
|
||||
</small>
|
||||
</span>`;
|
||||
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -277,7 +288,7 @@ function renderSalesOrder(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "SalesOrderShipment" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSalesOrderShipment(name, data, parameters, options) {
|
||||
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
|
||||
|
||||
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
|
||||
@ -294,7 +305,7 @@ function renderSalesOrderShipment(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "PartCategory" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPartCategory(name, data, parameters, options) {
|
||||
function renderPartCategory(name, data, parameters={}, options={}) {
|
||||
|
||||
var level = '- '.repeat(data.level);
|
||||
|
||||
@ -310,7 +321,7 @@ function renderPartCategory(name, data, parameters, options) {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPartParameterTemplate(name, data, parameters, options) {
|
||||
function renderPartParameterTemplate(name, data, parameters={}, options={}) {
|
||||
|
||||
var units = '';
|
||||
|
||||
@ -326,7 +337,7 @@ function renderPartParameterTemplate(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "ManufacturerPart" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderManufacturerPart(name, data, parameters, options) {
|
||||
function renderManufacturerPart(name, data, parameters={}, options={}) {
|
||||
|
||||
var manufacturer_image = null;
|
||||
var part_image = null;
|
||||
@ -355,7 +366,7 @@ function renderManufacturerPart(name, data, parameters, options) {
|
||||
|
||||
// Renderer for "SupplierPart" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSupplierPart(name, data, parameters, options) {
|
||||
function renderSupplierPart(name, data, parameters={}, options={}) {
|
||||
|
||||
var supplier_image = null;
|
||||
var part_image = null;
|
||||
|
@ -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) {
|
||||
|
@ -491,13 +491,50 @@ function duplicateBom(part_id, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a "badge" label showing stock information for this particular part
|
||||
*/
|
||||
function partStockLabel(part, options={}) {
|
||||
|
||||
if (part.in_stock) {
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`;
|
||||
// There IS stock available for this part
|
||||
|
||||
// Is stock "low" (below the 'minimum_stock' quantity)?
|
||||
if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) {
|
||||
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "Low stock" %}: ${part.in_stock}${part.units}</span>`;
|
||||
} else if (part.unallocated_stock == 0) {
|
||||
if (part.ordering) {
|
||||
// There is no available stock, but stock is on order
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
|
||||
} else if (part.building) {
|
||||
// There is no available stock, but stock is being built
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
|
||||
} else {
|
||||
// There is no available stock at all
|
||||
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "No stock available" %}</span>`;
|
||||
}
|
||||
} else if (part.unallocated_stock < part.in_stock) {
|
||||
// Unallocated quanttiy is less than total quantity
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}${part.units}</span>`;
|
||||
} else {
|
||||
// Stock is completely available
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}${part.units}</span>`;
|
||||
}
|
||||
} else {
|
||||
// There IS NO stock available for this part
|
||||
|
||||
if (part.ordering) {
|
||||
// There is no stock, but stock is on order
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
|
||||
} else if (part.building) {
|
||||
// There is no stock, but stock is being built
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
|
||||
} else {
|
||||
// There is no stock
|
||||
return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -1160,12 +1197,14 @@ function partGridTile(part) {
|
||||
|
||||
if (!part.in_stock) {
|
||||
stock = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
|
||||
} else if (!part.unallocated_stock) {
|
||||
stock = `<span class='badge rounded-pill bg-warning'>{% trans "Not available" %}</span>`;
|
||||
}
|
||||
|
||||
rows += `<tr><td><b>{% trans "Stock" %}</b></td><td>${stock}</td></tr>`;
|
||||
|
||||
if (part.on_order) {
|
||||
rows += `<tr><td><b>{$ trans "On Order" %}</b></td><td>${part.on_order}</td></tr>`;
|
||||
if (part.ordering) {
|
||||
rows += `<tr><td><b>{% trans "On Order" %}</b></td><td>${part.ordering}</td></tr>`;
|
||||
}
|
||||
|
||||
if (part.building) {
|
||||
@ -1322,32 +1361,48 @@ function loadPartTable(table, url, options={}) {
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
field: 'in_stock',
|
||||
field: 'unallocated_stock',
|
||||
title: '{% trans "Stock" %}',
|
||||
searchable: false,
|
||||
formatter: function(value, row) {
|
||||
var link = '?display=part-stock';
|
||||
|
||||
if (value) {
|
||||
if (row.in_stock) {
|
||||
// There IS stock available for this part
|
||||
|
||||
// Is stock "low" (below the 'minimum_stock' quantity)?
|
||||
if (row.minimum_stock && row.minimum_stock > value) {
|
||||
if (row.minimum_stock && row.minimum_stock > row.in_stock) {
|
||||
value += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Low stock" %}</span>`;
|
||||
}
|
||||
|
||||
} else if (row.on_order) {
|
||||
// There is no stock available, but stock is on order
|
||||
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.on_order}</span>`;
|
||||
} else if (value == 0) {
|
||||
if (row.ordering) {
|
||||
// There is no available stock, but stock is on order
|
||||
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.ordering}</span>`;
|
||||
link = '?display=purchase-orders';
|
||||
} else if (row.building) {
|
||||
// There is no stock available, but stock is being built
|
||||
// There is no available stock, but stock is being built
|
||||
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "Building" %}: ${row.building}</span>`;
|
||||
link = '?display=build-orders';
|
||||
} else {
|
||||
// There is no stock available
|
||||
// There is no available stock
|
||||
value = `0<span class='badge badge-right rounded-pill bg-warning'>{% trans "No stock available" %}</span>`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// There IS NO stock available for this part
|
||||
|
||||
if (row.ordering) {
|
||||
// There is no stock, but stock is on order
|
||||
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.ordering}</span>`;
|
||||
link = '?display=purchase-orders';
|
||||
} else if (row.building) {
|
||||
// There is no stock, but stock is being built
|
||||
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "Building" %}: ${row.building}</span>`;
|
||||
link = '?display=build-orders';
|
||||
} else {
|
||||
// There is no stock
|
||||
value = `0<span class='badge badge-right rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
return renderLink(value, `/part/${row.pk}/${link}`);
|
||||
}
|
||||
|
329
InvenTree/templates/js/translated/search.js
Normal file
329
InvenTree/templates/js/translated/search.js
Normal file
@ -0,0 +1,329 @@
|
||||
{% load i18n %}
|
||||
|
||||
/* globals
|
||||
*/
|
||||
|
||||
/* exported
|
||||
closeSearchPanel,
|
||||
openSearchPanel,
|
||||
searchTextChanged,
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Callback when the search panel is closed
|
||||
*/
|
||||
function closeSearchPanel() {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Callback when the search panel is opened.
|
||||
* Ensure the panel is in a known state
|
||||
*/
|
||||
function openSearchPanel() {
|
||||
|
||||
var panel = $('#offcanvas-search');
|
||||
|
||||
clearSearchResults();
|
||||
|
||||
panel.find('#search-input').on('keyup change', searchTextChanged);
|
||||
|
||||
// Callback for "clear search" button
|
||||
panel.find('#search-clear').click(function(event) {
|
||||
|
||||
// Prevent this button from actually submitting the form
|
||||
event.preventDefault();
|
||||
|
||||
panel.find('#search-input').val('');
|
||||
clearSearchResults();
|
||||
});
|
||||
|
||||
// Callback for the "close search" button
|
||||
panel.find('#search-close').click(function(event) {
|
||||
// Prevent this button from actually submitting the form
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
var searchInputTimer = null;
|
||||
var searchText = null;
|
||||
var searchTextCurrent = null;
|
||||
var searchQueries = [];
|
||||
|
||||
function searchTextChanged(event) {
|
||||
|
||||
searchText = $('#offcanvas-search').find('#search-input').val();
|
||||
|
||||
clearTimeout(searchInputTimer);
|
||||
searchInputTimer = setTimeout(updateSearch, 250);
|
||||
};
|
||||
|
||||
|
||||
function updateSearch() {
|
||||
|
||||
if (searchText == searchTextCurrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSearchResults();
|
||||
|
||||
if (searchText.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchTextCurrent = searchText;
|
||||
|
||||
// Cancel any previous AJAX requests
|
||||
searchQueries.forEach(function(query) {
|
||||
query.abort();
|
||||
});
|
||||
|
||||
searchQueries = [];
|
||||
|
||||
// Show the "searching" text
|
||||
$('#offcanvas-search').find('#search-pending').show();
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
|
||||
|
||||
var params = {};
|
||||
|
||||
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
||||
params.active = false;
|
||||
}
|
||||
|
||||
// Search for matching parts
|
||||
addSearchQuery(
|
||||
'part',
|
||||
'{% trans "Parts" %}',
|
||||
'{% url "api-part-list" %}',
|
||||
params,
|
||||
renderPart,
|
||||
{
|
||||
url: '/part',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
|
||||
// Search for matching part categories
|
||||
addSearchQuery(
|
||||
'category',
|
||||
'{% trans "Part Categories" %}',
|
||||
'{% url "api-part-category-list" %}',
|
||||
{},
|
||||
renderPartCategory,
|
||||
{
|
||||
url: '/part/category',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
|
||||
// Search for matching stock items
|
||||
addSearchQuery(
|
||||
'stock',
|
||||
'{% trans "Stock Items" %}',
|
||||
'{% url "api-stock-list" %}',
|
||||
{
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
renderStockItem,
|
||||
{
|
||||
url: '/stock/item',
|
||||
render_location_detail: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
|
||||
// Search for matching stock locations
|
||||
addSearchQuery(
|
||||
'location',
|
||||
'{% trans "Stock Locations" %}',
|
||||
'{% url "api-location-list" %}',
|
||||
{},
|
||||
renderStockLocation,
|
||||
{
|
||||
url: '/stock/location',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
|
||||
// Search for matching companies
|
||||
addSearchQuery(
|
||||
'company',
|
||||
'{% trans "Companies" %}',
|
||||
'{% url "api-company-list" %}',
|
||||
{},
|
||||
renderCompany,
|
||||
{
|
||||
url: '/company',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
|
||||
// Search for matching purchase orders
|
||||
addSearchQuery(
|
||||
'purchaseorder',
|
||||
'{% trans "Purchase Orders" %}',
|
||||
'{% url "api-po-list" %}',
|
||||
{
|
||||
supplier_detail: true,
|
||||
outstanding: true,
|
||||
},
|
||||
renderPurchaseOrder,
|
||||
{
|
||||
url: '/order/purchase-order',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
|
||||
// Search for matching sales orders
|
||||
addSearchQuery(
|
||||
'salesorder',
|
||||
'{% trans "Sales Orders" %}',
|
||||
'{% url "api-so-list" %}',
|
||||
{
|
||||
customer_detail: true,
|
||||
outstanding: true,
|
||||
},
|
||||
renderSalesOrder,
|
||||
{
|
||||
url: '/order/sales-order',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Wait until all the pending queries are completed
|
||||
$.when.apply($, searchQueries).done(function() {
|
||||
$('#offcanvas-search').find('#search-pending').hide();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function clearSearchResults() {
|
||||
|
||||
var panel = $('#offcanvas-search');
|
||||
|
||||
// Ensure the 'no results found' element is visible
|
||||
panel.find('#search-no-results').show();
|
||||
|
||||
// Ensure that the 'searching' element is hidden
|
||||
panel.find('#search-pending').hide();
|
||||
|
||||
// Delete any existing search results
|
||||
panel.find('#search-results').empty();
|
||||
|
||||
// Finally, grab keyboard focus in the search bar
|
||||
panel.find('#search-input').focus();
|
||||
}
|
||||
|
||||
|
||||
function addSearchQuery(key, title, query_url, query_params, render_func, render_params={}) {
|
||||
|
||||
// Include current search term
|
||||
query_params.search = searchTextCurrent;
|
||||
|
||||
// How many results to show in each group?
|
||||
query_params.offset = 0;
|
||||
query_params.limit = user_settings.SEARCH_PREVIEW_RESULTS;
|
||||
|
||||
// Do not display "pk" value for search results
|
||||
render_params.render_pk = false;
|
||||
|
||||
// Add the result group to the panel
|
||||
$('#offcanvas-search').find('#search-results').append(`
|
||||
<div class='search-result-group-wrapper' id='search-results-wrapper-${key}'></div>
|
||||
`);
|
||||
|
||||
var request = inventreeGet(
|
||||
query_url,
|
||||
query_params,
|
||||
{
|
||||
success: function(response) {
|
||||
addSearchResults(
|
||||
key,
|
||||
response.results,
|
||||
title,
|
||||
render_func,
|
||||
render_params,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add the query to the stack
|
||||
searchQueries.push(request);
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Add a group of results to the list
|
||||
function addSearchResults(key, results, title, renderFunc, renderParams={}) {
|
||||
|
||||
if (results.length == 0) {
|
||||
// Do not display this group, as there are no results
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = $('#offcanvas-search');
|
||||
|
||||
// Ensure the 'no results found' element is hidden
|
||||
panel.find('#search-no-results').hide();
|
||||
|
||||
panel.find(`#search-results-wrapper-${key}`).append(`
|
||||
<div class='search-result-group' id='search-results-${key}'>
|
||||
<div class='search-result-header' style='display: flex;'>
|
||||
<h5>${title}</h5>
|
||||
<span class='flex' style='flex-grow: 1;'></span>
|
||||
<div class='search-result-group-buttons btn-group float-right' role='group'>
|
||||
<button class='btn btn-outline-secondary' id='hide-results-${key}' title='{% trans "Minimize results" %}'>
|
||||
<span class='fas fa-chevron-up'></span>
|
||||
</button>
|
||||
<button class='btn btn-outline-secondary' id='remove-results-${key}' title='{% trans "Remove results" %}'>
|
||||
<span class='fas fa-times icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collapse search-result-list' id='search-result-list-${key}'>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
results.forEach(function(result) {
|
||||
|
||||
var pk = result.pk || result.id;
|
||||
|
||||
var html = renderFunc(key, result, renderParams);
|
||||
|
||||
if (renderParams.url) {
|
||||
html = `<a href='${renderParams.url}/${pk}/'>` + html + `</a>`;
|
||||
}
|
||||
|
||||
var result_html = `
|
||||
<div class='search-result-entry' id='search-result-${key}-${pk}'>
|
||||
${html}
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.find(`#search-result-list-${key}`).append(result_html);
|
||||
});
|
||||
|
||||
// Expand results panel
|
||||
panel.find(`#search-result-list-${key}`).toggle();
|
||||
|
||||
// Add callback for "toggle" button
|
||||
panel.find(`#hide-results-${key}`).click(function() {
|
||||
panel.find(`#search-result-list-${key}`).toggle();
|
||||
});
|
||||
|
||||
// Add callback for "remove" button
|
||||
panel.find(`#remove-results-${key}`).click(function() {
|
||||
panel.find(`#search-results-${key}`).remove();
|
||||
});
|
||||
}
|
@ -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');
|
||||
|
||||
|
@ -427,12 +427,16 @@ function getAvailableTableFilters(tableKey) {
|
||||
},
|
||||
has_stock: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Stock available" %}',
|
||||
title: '{% trans "In stock" %}',
|
||||
},
|
||||
low_stock: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Low stock" %}',
|
||||
},
|
||||
unallocated_stock: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Available stock" %}',
|
||||
},
|
||||
assembly: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Assembly" %}',
|
||||
|
@ -87,18 +87,25 @@
|
||||
{% if demo %}
|
||||
{% include "navbar_demo.html" %}
|
||||
{% endif %}
|
||||
{% include "search_form.html" %}
|
||||
|
||||
<ul class='navbar-nav flex-row'>
|
||||
|
||||
<li class='nav-item me-2'>
|
||||
<button data-bs-toggle='offcanvas' data-bs-target="#offcanvas-search" class='btn position-relative' title='{% trans "Search" %}'>
|
||||
<span class='fas fa-search'></span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{% if barcodes %}
|
||||
<li class='nav-item' id='navbar-barcode-li'>
|
||||
<button id='barcode-scan' class='btn btn-secondary' title='{% trans "Scan Barcode" %}'>
|
||||
<button id='barcode-scan' class='btn position-relative' title='{% trans "Scan Barcode" %}'>
|
||||
<span class='fas fa-qrcode'></span>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class='nav-item me-2'>
|
||||
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" class='btn position-relative' title='{% trans "Show Notifications" %}'>
|
||||
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvas-notification" class='btn position-relative' title='{% trans "Show Notifications" %}'>
|
||||
<span class='fas fa-bell'></span>
|
||||
<span class="position-absolute top-100 start-100 translate-middle badge rounded-pill bg-danger d-none" id="notification-alert">
|
||||
<span class="visually-hidden">{% trans "New Notifications" %}</span>
|
||||
|
@ -1,7 +1,8 @@
|
||||
{% load i18n %}
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" data-bs-scroll="true" aria-labelledby="offcanvasRightLabel">
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvas-notification" data-bs-scroll="true" aria-labelledby="offcanvas-notification-label">
|
||||
<div class="offcanvas-header">
|
||||
<h5 id="offcanvasRightLabel">{% trans "Notifications" %}</h5>
|
||||
<h5 id="offcanvas-notification-label">{% trans "Notifications" %}</h5>
|
||||
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
@ -12,3 +13,4 @@
|
||||
<a href="{% url 'notifications' %}">{% trans "Show all notifications and history" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
43
InvenTree/templates/search.html
Normal file
43
InvenTree/templates/search.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="offcanvas offcanvas-end search-result-panel" tabindex="-1" id="offcanvas-search" data-bs-scroll="true" aria-labelledby="offcanvas-search-label">
|
||||
<div class="offcanvas-header">
|
||||
<form action='{% url "search" %}' method='post' class='d-flex' style='width: 100%;'>
|
||||
{% csrf_token %}
|
||||
<div class='input-group'>
|
||||
<input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-input" placeholder="{% trans 'Search' %}" autofocus>
|
||||
<button type='submit' id='search-complete' class='btn btn-outline-secondary' title='{% trans "Show full search results" %}'>
|
||||
<span class='fas fa-search'></span>
|
||||
</button>
|
||||
<button id='search-clear' class='btn btn-outline-secondary' title='{% trans "Clear search" %}'>
|
||||
<span class='fas fa-backspace'></span>
|
||||
</button>
|
||||
<!--
|
||||
<button id='search-filter' class="btn btn-outline-secondary" title='{% trans "Filter results" %}'>
|
||||
<span class='fas fa-filter'></span>
|
||||
</button>
|
||||
-->
|
||||
<button id='search-close' class="btn btn-outline-secondary" data-bs-dismiss='offcanvas' title='{% trans "Close search menu" %}'>
|
||||
<span class='fas fa-times icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<div id="search-center">
|
||||
<p id='search-pending' class='text-muted' display='none'>
|
||||
<em>{% trans "Searching" %}...</em>
|
||||
<span class='float-right'>
|
||||
<span class='fas fa-spinner fa-spin'></span>
|
||||
</span>
|
||||
</p>
|
||||
<p id='search-no-results' class='text-muted'>
|
||||
<em>{% trans "No search results" %}</em>
|
||||
</p>
|
||||
<div id='search-results'>
|
||||
<!-- Search results go here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Base python requirements for docker containers
|
||||
|
||||
# Basic package requirements
|
||||
setuptools>=57.4.0
|
||||
setuptools==60.0.5
|
||||
wheel>=0.37.0
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user