diff --git a/.github/workflows/html.yaml b/.github/workflows/html.yaml index 069da7cbb4..d0084ae032 100644 --- a/.github/workflows/html.yaml +++ b/.github/workflows/html.yaml @@ -43,7 +43,6 @@ jobs: run: | npm install markuplint npx markuplint InvenTree/build/templates/build/*.html - npx markuplint InvenTree/common/templates/common/*.html npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/part/templates/part/*.html diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index e7f78554f9..4294c943ba 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -98,7 +98,7 @@ class InvenTreeMetadata(SimpleMetadata): Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value """ - + serializer_info = super().get_serializer_info(serializer) model_class = None @@ -108,6 +108,13 @@ class InvenTreeMetadata(SimpleMetadata): model_fields = model_meta.get_field_info(model_class) + model_default_func = getattr(model_class, 'api_defaults', None) + + if model_default_func: + model_default_values = model_class.api_defaults(self.request) + else: + model_default_values = {} + # Iterate through simple fields for name, field in model_fields.fields.items(): @@ -123,6 +130,9 @@ class InvenTreeMetadata(SimpleMetadata): serializer_info[name]['default'] = default + elif name in model_default_values: + serializer_info[name]['default'] = model_default_values[name] + # Iterate through relations for name, relation in model_fields.relations.items(): @@ -141,6 +151,9 @@ class InvenTreeMetadata(SimpleMetadata): if 'help_text' not in serializer_info[name] and hasattr(relation.model_field, 'help_text'): serializer_info[name]['help_text'] = relation.model_field.help_text + if name in model_default_values: + serializer_info[name]['default'] = model_default_values[name] + except AttributeError: pass diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index b2e3b36354..cd9ce410de 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -34,8 +34,7 @@ } .login-header { - padding-right: 30px; - margin-right: 30px; + margin-right: 5px; } .login-container input { @@ -125,15 +124,14 @@ align-content: center; } -.qr-container { - width: 100%; - align-content: center; - object-fit: fill; -} - .navbar { border-bottom: 1px solid #ccc; background-color: var(--secondary-color); + box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); +} + +.inventree-navbar-menu { + position: absolute !important; } .navbar-brand { @@ -544,6 +542,7 @@ .inventree-body { width: 100%; padding: 5px; + padding-right: 0; } .inventree-pre-content { @@ -829,11 +828,12 @@ input[type="submit"] { color: var(--bs-body-color); background-color: var(--secondary-color); border-bottom: 1px solid var(--border-color); + box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); } .panel { box-shadow: 2px 2px #DDD; - margin-bottom: 20px; + margin-bottom: .75rem; background-color: #fff; border: 1px solid #ccc; } diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index e0c88c6b7e..0f7f555073 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -43,7 +43,6 @@ from .views import CurrencyRefreshView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView -from common.views import SettingEdit, UserSettingEdit from common.models import InvenTreeSetting from .api import InfoView, NotFoundView @@ -55,6 +54,7 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ url(r'^barcode/', include(barcode_api_urls)), + url(r'^settings/', include(common_api_urls)), url(r'^part/', include(part_api_urls)), url(r'^bom/', include(bom_api_urls)), url(r'^company/', include(company_api_urls)), @@ -89,9 +89,6 @@ settings_urls = [ url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'), - url(r'^(?P\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'), - url(r'^(?P\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), - # Catch any other urls url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), ] diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 48539713f2..935a0bed37 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 16 +INVENTREE_API_VERSION = 17 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v17 -> 2021-11-09 + - Adds API endpoints for GLOBAL and USER settings objects + - Ref: https://github.com/inventree/InvenTree/pull/2275 + v16 -> 2021-10-17 - Adds API endpoint for completing build order outputs diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 0dd6a404e5..4fe22f7e0e 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -47,7 +47,7 @@ def get_next_build_number(): """ if Build.objects.count() == 0: - return + return '0001' build = Build.objects.exclude(reference=None).last() @@ -100,13 +100,28 @@ class Build(MPTTModel, ReferenceIndexingMixin): return reverse('api-build-list') def api_instance_filters(self): - + return { 'parent': { 'exclude_tree': self.pk, } } + @classmethod + def api_defaults(cls, request): + """ + Return default values for this model when issuing an API OPTIONS request + """ + + defaults = { + 'reference': get_next_build_number(), + } + + if request and request.user: + defaults['issued_by'] = request.user.pk + + return defaults + def save(self, *args, **kwargs): self.rebuild_reference_field() diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index c7281e4950..f71bf5b958 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -11,12 +11,16 @@ from django.http.response import HttpResponse from django.utils.decorators import method_decorator from django.urls import path from django.views.decorators.csrf import csrf_exempt +from django.conf.urls import url, include from rest_framework.views import APIView from rest_framework.exceptions import NotAcceptable, NotFound +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, generics, permissions from django_q.tasks import async_task -from .models import WebhookEndpoint, WebhookMessage +import common.models +import common.serializers from InvenTree.helpers import inheritors @@ -36,7 +40,7 @@ class WebhookView(CsrfExemptMixin, APIView): """ authentication_classes = [] permission_classes = [] - model_class = WebhookEndpoint + model_class = common.models.WebhookEndpoint run_async = False def post(self, request, endpoint, *args, **kwargs): @@ -67,7 +71,7 @@ class WebhookView(CsrfExemptMixin, APIView): return HttpResponse(data) def _process_payload(self, message_id): - message = WebhookMessage.objects.get(message_id=message_id) + message = common.models.WebhookMessage.objects.get(message_id=message_id) self._process_result( self.webhook.process_payload(message, message.body, message.header), message, @@ -98,6 +102,141 @@ class WebhookView(CsrfExemptMixin, APIView): raise NotFound() +class SettingsList(generics.ListAPIView): + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + ordering_fields = [ + 'pk', + 'key', + 'name', + ] + + search_fields = [ + 'key', + ] + + +class GlobalSettingsList(SettingsList): + """ + API endpoint for accessing a list of global settings objects + """ + + queryset = common.models.InvenTreeSetting.objects.all() + serializer_class = common.serializers.GlobalSettingsSerializer + + +class GlobalSettingsPermissions(permissions.BasePermission): + """ + Special permission class to determine if the user is "staff" + """ + + def has_permission(self, request, view): + """ + Check that the requesting user is 'admin' + """ + + try: + user = request.user + + return user.is_staff + except AttributeError: + return False + + +class GlobalSettingsDetail(generics.RetrieveUpdateAPIView): + """ + Detail view for an individual "global setting" object. + + - User must have 'staff' status to view / edit + """ + + queryset = common.models.InvenTreeSetting.objects.all() + serializer_class = common.serializers.GlobalSettingsSerializer + + permission_classes = [ + GlobalSettingsPermissions, + ] + + +class UserSettingsList(SettingsList): + """ + API endpoint for accessing a list of user settings objects + """ + + queryset = common.models.InvenTreeUserSetting.objects.all() + serializer_class = common.serializers.UserSettingsSerializer + + def filter_queryset(self, queryset): + """ + Only list settings which apply to the current user + """ + + try: + user = self.request.user + except AttributeError: + return common.models.InvenTreeUserSetting.objects.none() + + queryset = super().filter_queryset(queryset) + + queryset = queryset.filter(user=user) + + return queryset + + +class UserSettingsPermissions(permissions.BasePermission): + """ + Special permission class to determine if the user can view / edit a particular setting + """ + + def has_object_permission(self, request, view, obj): + + try: + user = request.user + except AttributeError: + return False + + return user == obj.user + + +class UserSettingsDetail(generics.RetrieveUpdateAPIView): + """ + Detail view for an individual "user setting" object + + - User can only view / edit settings their own settings objects + """ + + queryset = common.models.InvenTreeUserSetting.objects.all() + serializer_class = common.serializers.UserSettingsSerializer + + permission_classes = [ + UserSettingsPermissions, + ] + + common_api_urls = [ path('webhook//', WebhookView.as_view(), name='api-webhook'), + + # User settings + url(r'^user/', include([ + # User Settings Detail + url(r'^(?P\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'), + + # User Settings List + url(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), + ])), + + # Global settings + url(r'^global/', include([ + # Global Settings Detail + url(r'^(?P\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), + + # Global Settings List + url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), + ])), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index aec810de12..1692dca948 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -42,6 +42,19 @@ import logging logger = logging.getLogger('inventree') +class EmptyURLValidator(URLValidator): + + def __call__(self, value): + + value = str(value).strip() + + if len(value) == 0: + pass + + else: + super().__call__(value) + + class BaseInvenTreeSetting(models.Model): """ An base InvenTreeSetting object is a key:value pair used for storing @@ -53,6 +66,16 @@ class BaseInvenTreeSetting(models.Model): class Meta: abstract = True + def save(self, *args, **kwargs): + """ + Enforce validation and clean before saving + """ + + self.clean() + self.validate_unique() + + super().save() + @classmethod def allValues(cls, user=None): """ @@ -353,6 +376,11 @@ class BaseInvenTreeSetting(models.Model): except (ValueError): raise ValidationError(_('Must be an integer value')) + options = self.valid_options() + + if options and self.value not in options: + raise ValidationError(_("Chosen value is not a valid option")) + if validator is not None: self.run_validator(validator) @@ -419,6 +447,18 @@ class BaseInvenTreeSetting(models.Model): return self.__class__.get_setting_choices(self.key) + def valid_options(self): + """ + Return a list of valid options for this setting + """ + + choices = self.choices() + + if not choices: + return None + + return [opt[0] for opt in choices] + def is_bool(self): """ Check if this setting is required to be a boolean value @@ -437,6 +477,20 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) + def setting_type(self): + """ + Return the field type identifier for this setting object + """ + + if self.is_bool(): + return 'boolean' + + elif self.is_int(): + return 'integer' + + else: + return 'string' + @classmethod def validator_is_bool(cls, validator): @@ -554,7 +608,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'INVENTREE_BASE_URL': { 'name': _('Base URL'), 'description': _('Base URL for server instance'), - 'validator': URLValidator(), + 'validator': EmptyURLValidator(), 'default': '', }, @@ -873,7 +927,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): }, 'SIGNUP_GROUP': { 'name': _('Group on signup'), - 'description': _('Group new user are asigned on registration'), + 'description': _('Group to which new users are assigned on registration'), 'default': '', 'choices': settings_group_options }, @@ -914,6 +968,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): help_text=_('Settings key (must be unique - case insensitive'), ) + def to_native_value(self): + """ + Return the "pythonic" value, + e.g. convert "True" to True, and "1" to 1 + """ + + return self.__class__.get_setting(self.key) + class InvenTreeUserSetting(BaseInvenTreeSetting): """ @@ -1124,6 +1186,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'user__id': kwargs['user'].id } + def to_native_value(self): + """ + Return the "pythonic" value, + e.g. convert "True" to True, and "1" to 1 + """ + + return self.__class__.get_setting(self.key, user=self.user) + class PriceBreak(models.Model): """ diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 99ac03cdfd..4a27e3f30e 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,3 +1,85 @@ """ JSON serializers for common components """ + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from InvenTree.serializers import InvenTreeModelSerializer + +from rest_framework import serializers + +from common.models import InvenTreeSetting, InvenTreeUserSetting + + +class SettingsSerializer(InvenTreeModelSerializer): + """ + Base serializer for a settings object + """ + + key = serializers.CharField(read_only=True) + + name = serializers.CharField(read_only=True) + + description = serializers.CharField(read_only=True) + + type = serializers.CharField(source='setting_type', read_only=True) + + choices = serializers.SerializerMethodField() + + def get_choices(self, obj): + """ + Returns the choices available for a given item + """ + + results = [] + + choices = obj.choices() + + if choices: + for choice in choices: + results.append({ + 'value': choice[0], + 'display_name': choice[1], + }) + + return results + + +class GlobalSettingsSerializer(SettingsSerializer): + """ + Serializer for the InvenTreeSetting model + """ + + class Meta: + model = InvenTreeSetting + fields = [ + 'pk', + 'key', + 'value', + 'name', + 'description', + 'type', + 'choices', + ] + + +class UserSettingsSerializer(SettingsSerializer): + """ + Serializer for the InvenTreeUserSetting model + """ + + user = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = InvenTreeUserSetting + fields = [ + 'pk', + 'key', + 'value', + 'name', + 'description', + 'user', + 'type', + 'choices', + ] diff --git a/InvenTree/common/templates/common/edit_setting.html b/InvenTree/common/templates/common/edit_setting.html deleted file mode 100644 index c479e268a5..0000000000 --- a/InvenTree/common/templates/common/edit_setting.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{{ block.super }} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/common/test_views.py b/InvenTree/common/test_views.py index 76a0a4516e..7d7bfde87e 100644 --- a/InvenTree/common/test_views.py +++ b/InvenTree/common/test_views.py @@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app # -*- coding: utf-8 -*- from __future__ import unicode_literals - -import json - -from django.test import TestCase -from django.urls import reverse -from django.contrib.auth import get_user_model - -from common.models import InvenTreeSetting - - -class SettingsViewTest(TestCase): - """ - Tests for the settings management views - """ - - fixtures = [ - 'settings', - ] - - def setUp(self): - super().setUp() - - # Create a user (required to access the views / forms) - self.user = get_user_model().objects.create_user( - username='username', - email='me@email.com', - password='password', - ) - - self.client.login(username='username', password='password') - - def get_url(self, pk): - return reverse('setting-edit', args=(pk,)) - - def get_setting(self, title): - - return InvenTreeSetting.get_setting_object(title) - - def get(self, url, status=200): - - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, status) - - data = json.loads(response.content) - - return response, data - - def post(self, url, data, valid=None): - - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - json_data = json.loads(response.content) - - # If a particular status code is required - if valid is not None: - if valid: - self.assertEqual(json_data['form_valid'], True) - else: - self.assertEqual(json_data['form_valid'], False) - - form_errors = json.loads(json_data['form_errors']) - - return json_data, form_errors - - def test_instance_name(self): - """ - Test that we can get the settings view for particular setting objects. - """ - - # Start with something basic - load the settings view for INVENTREE_INSTANCE - setting = self.get_setting('INVENTREE_INSTANCE') - - self.assertIsNotNone(setting) - self.assertEqual(setting.value, 'My very first InvenTree Instance') - - url = self.get_url(setting.pk) - - self.get(url) - - new_name = 'A new instance name!' - - # Change the instance name via the form - data, errors = self.post(url, {'value': new_name}, valid=True) - - name = InvenTreeSetting.get_setting('INVENTREE_INSTANCE') - - self.assertEqual(name, new_name) - - def test_choices(self): - """ - Tests for a setting which has choices - """ - - setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX') - - # Default value! - self.assertEqual(setting.value, 'PO') - - url = self.get_url(setting.pk) - - # Try posting an invalid currency option - data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True) - - def test_binary_values(self): - """ - Test for binary value - """ - - setting = InvenTreeSetting.get_setting_object('PART_COMPONENT') - - self.assertTrue(setting.as_bool()) - - url = self.get_url(setting.pk) - - setting.value = True - setting.save() - - # Try posting some invalid values - # The value should be "cleaned" and stay the same - for value in ['', 'abc', 'cat', 'TRUETRUETRUE']: - self.post(url, {'value': value}, valid=True) - - # Try posting some valid (True) values - for value in [True, 'True', '1', 'yes']: - self.post(url, {'value': value}, valid=True) - self.assertTrue(InvenTreeSetting.get_setting('PART_COMPONENT')) - - # Try posting some valid (False) values - for value in [False, 'False']: - self.post(url, {'value': value}, valid=True) - self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT')) - - def test_part_name_format(self): - """ - Try posting some valid and invalid name formats for PART_NAME_FORMAT - """ - setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT') - - # test default value - self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}" - "{{ ' | ' if part.revision }}{{ part.revision if part.revision }}") - - url = self.get_url(setting.pk) - - # Try posting an invalid part name format - invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}'] - for invalid_value in invalid_values: - self.post(url, {'value': invalid_value}, valid=False) - - # try posting valid value - new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}" - self.post(url, {'value': new_format}, valid=True) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 6cac8bbb19..4b8310ddd2 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -8,138 +8,18 @@ from __future__ import unicode_literals import os from django.utils.translation import ugettext_lazy as _ -from django.forms import CheckboxInput, Select from django.conf import settings from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView from crispy_forms.helper import FormHelper -from InvenTree.views import AjaxUpdateView, AjaxView -from InvenTree.helpers import str2bool +from InvenTree.views import AjaxView -from . import models from . import forms from .files import FileManager -class SettingEdit(AjaxUpdateView): - """ - View for editing an InvenTree key:value settings object, - (or creating it if the key does not already exist) - """ - - model = models.InvenTreeSetting - ajax_form_title = _('Change Setting') - form_class = forms.SettingEditForm - ajax_template_name = "common/edit_setting.html" - - def get_context_data(self, **kwargs): - """ - Add extra context information about the particular setting object. - """ - - ctx = super().get_context_data(**kwargs) - - setting = self.get_object() - - ctx['key'] = setting.key - ctx['value'] = setting.value - ctx['name'] = self.model.get_setting_name(setting.key) - ctx['description'] = self.model.get_setting_description(setting.key) - - return ctx - - def get_data(self): - """ - Custom data to return to the client after POST success - """ - - data = {} - - setting = self.get_object() - - data['pk'] = setting.pk - data['key'] = setting.key - data['value'] = setting.value - data['is_bool'] = setting.is_bool() - data['is_int'] = setting.is_int() - - return data - - def get_form(self): - """ - Override default get_form behaviour - """ - - form = super().get_form() - - setting = self.get_object() - - choices = setting.choices() - - if choices is not None: - form.fields['value'].widget = Select(choices=choices) - elif setting.is_bool(): - form.fields['value'].widget = CheckboxInput() - - self.object.value = str2bool(setting.value) - form.fields['value'].value = str2bool(setting.value) - - name = self.model.get_setting_name(setting.key) - - if name: - form.fields['value'].label = name - - description = self.model.get_setting_description(setting.key) - - if description: - form.fields['value'].help_text = description - - return form - - def validate(self, setting, form): - """ - Perform custom validation checks on the form data. - """ - - data = form.cleaned_data - - value = data.get('value', None) - - if setting.choices(): - """ - If a set of choices are provided for a given setting, - the provided value must be one of those choices. - """ - - choices = [choice[0] for choice in setting.choices()] - - if value not in choices: - form.add_error('value', _('Supplied value is not allowed')) - - if setting.is_bool(): - """ - If a setting is defined as a boolean setting, - the provided value must look somewhat like a boolean value! - """ - - if not str2bool(value, test=True) and not str2bool(value, test=False): - form.add_error('value', _('Supplied value must be a boolean')) - - -class UserSettingEdit(SettingEdit): - """ - View for editing an InvenTree key:value user settings object, - (or creating it if the key does not already exist) - """ - - model = models.InvenTreeUserSetting - ajax_form_title = _('Change User Setting') - form_class = forms.SettingEditForm - ajax_template_name = "common/edit_setting.html" - - class MultiStepFormView(SessionWizardView): """ Setup basic methods of multi-step form diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0c45e3746a..42106a7376 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -37,7 +37,7 @@ def get_next_po_number(): """ if PurchaseOrder.objects.count() == 0: - return + return '0001' order = PurchaseOrder.objects.exclude(reference=None).last() @@ -66,7 +66,7 @@ def get_next_so_number(): """ if SalesOrder.objects.count() == 0: - return + return '0001' order = SalesOrder.objects.exclude(reference=None).last() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 8e737e217b..410ac02ddb 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -242,6 +242,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): help_text=_('Unique identifier field'), default='', required=False, + allow_null=True, allow_blank=True, ) diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 952319da10..0f705212db 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -16,7 +16,7 @@ {% block thumbnail %} + {% if order.customer %} {% trans "Customer" %} {{ order.customer.name }}{% include "clip.html"%} + {% endif %} {% if order.customer_reference %} diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 899fa9a6fc..b4eb956f9d 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -349,6 +349,31 @@ class PurchaseOrderReceiveTest(OrderTest): # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) + def test_null_barcode(self): + """ + Test than a "null" barcode field can be provided + """ + + # Set stock item barcode + item = StockItem.objects.get(pk=1) + item.save() + + # Test with "null" value + self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 50, + 'barcode': None, + } + ], + 'location': 1, + }, + expected_code=201 + ) + def test_invalid_barcodes(self): """ Tests for checking in items with invalid barcodes: diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 23aea29dbd..3ab616d96d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2050,10 +2050,10 @@ class Part(MPTTModel): if self.variant_of: parts.append(self.variant_of) - siblings = self.get_siblings(include_self=False) + siblings = self.get_siblings(include_self=False) - for sib in siblings: - parts.append(sib) + for sib in siblings: + parts.append(sib) filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts]) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 48677ee71d..b8e3dbe1f6 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -29,7 +29,7 @@ {% else %} - {% endif %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index bb7aea3abb..4ca7c80d65 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -24,7 +24,7 @@ {% endif %} {% if starred_directly %} - {% elif starred %} @@ -32,7 +32,7 @@ {% else %} - {% endif %} diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 36a99079ad..af88f4799f 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -257,7 +257,6 @@ class ReportPrintMixin: pages = [] try: - pdf = outputs[0].get_document().copy(pages).write_pdf() if len(outputs) > 1: # If more than one output is generated, merge them into a single file @@ -265,6 +264,8 @@ class ReportPrintMixin: doc = output.get_document() for page in doc.pages: pages.append(page) + + pdf = outputs[0].get_document().copy(pages).write_pdf() else: pdf = outputs[0].get_document().write_pdf() diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index f64c9b0704..0f8d81203a 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -53,6 +53,12 @@ + +{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} +{% if owner_control.value == "True" %} + {% authorized_owners item.owner as owners %} +{% endif %} + {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if roles.stock.change and not item.is_building %}
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index fa8cd755c7..05ffd14cac 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -62,26 +62,17 @@ $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); - var url = `/settings/${pk}/edit/`; + + var is_global = true; if ($(this).attr('user')){ - url += `user/`; + is_global = false; } - launchModalForm( - url, - { - success: function(response) { - - if (response.is_bool) { - var enabled = response.value.toLowerCase() == 'true'; - $(`#setting-value-${setting}`).prop('checked', enabled); - } else { - $(`#setting-value-${setting}`).html(response.value); - } - } - } - ); + editSetting(pk, { + global: is_global, + title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', + }); }); $("#edit-user").on('click', function() { diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index d1baf1ba6e..89fc67865a 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -212,26 +212,37 @@ {% trans "Select language" %}
- {% get_current_language as LANGUAGE_CODE %} {% get_available_languages as LANGUAGES %} {% get_language_info_list for LANGUAGES as languages %} + {% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %} {% for language in languages %} {% define language.code as lang_code %} {% define locale_stats|keyvalue:lang_code as lang_translated %} + {% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %} + {% if ALL_LANG or use_lang %} + {% endif %} {% endfor %}
+

{% trans "Some languages are not complete" %} + {% if ALL_LANG %} + . {% trans "Show only sufficent" %} + {% else %} + and hidden. {% trans "Show them too" %} + {% endif %} +

diff --git a/InvenTree/templates/about.html b/InvenTree/templates/about.html index 34d4bf25e4..34884da9d1 100644 --- a/InvenTree/templates/about.html +++ b/InvenTree/templates/about.html @@ -22,12 +22,12 @@ {% inventree_version %}{% include "clip.html" %} {% inventree_is_development as dev %} {% if dev %} - {% trans "Development Version" %} + {% trans "Development Version" %} {% else %} {% if up_to_date %} - {% trans "Up to Date" %} + {% trans "Up to Date" %} {% else %} - {% trans "Update Available" %} + {% trans "Update Available" %} {% endif %} {% endif %} diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index 7f2486bfcc..ea3795e87c 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -71,9 +71,12 @@ {% include "spacer.html" %}

{% inventree_title %}

-
-
{% block content %}{% endblock %}
+
+
+ {% block content %} + {% endblock %} +
diff --git a/InvenTree/templates/account/login.html b/InvenTree/templates/account/login.html index 73c49e46c0..fbe48224b4 100644 --- a/InvenTree/templates/account/login.html +++ b/InvenTree/templates/account/login.html @@ -32,6 +32,7 @@ for a account and sign in below:{% endblocktrans %}

{% endif %} +
diff --git a/InvenTree/templates/account/logout.html b/InvenTree/templates/account/logout.html index 7268599bb2..df37c76be4 100644 --- a/InvenTree/templates/account/logout.html +++ b/InvenTree/templates/account/logout.html @@ -14,6 +14,7 @@ {% if redirect_field_value %} {% endif %} +
{% trans "Back to Site" %} diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 856deac4be..e64f1c11d0 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -83,7 +83,7 @@
-
+
{% block alerts %}
@@ -190,6 +190,18 @@ $(document).ready(function () { {% endif %} moment.locale('{{ request.LANGUAGE_CODE }}'); + + // Account notifications + {% if messages %} + {% for message in messages %} + showMessage( + '{{ message }}', + { + style: 'info', + } + ); + {% endfor %} + {% endif %} }); diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index cb21e1fefc..8201dc8374 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -1,6 +1,7 @@ {% load inventree_extras %} /* exported + editSetting, user_settings, global_settings, */ @@ -18,3 +19,83 @@ const global_settings = { {{ key }}: {% primitive_to_javascript value %}, {% endfor %} }; + +/* + * Edit a setting value + */ +function editSetting(pk, options={}) { + + // Is this a global setting or a user setting? + var global = options.global || false; + + var url = ''; + + if (global) { + url = `/api/settings/global/${pk}/`; + } else { + url = `/api/settings/user/${pk}/`; + } + + // First, read the settings object from the server + inventreeGet(url, {}, { + success: function(response) { + + if (response.choices && response.choices.length > 0) { + response.type = 'choice'; + } + + // Construct the field + var fields = { + value: { + label: response.name, + help_text: response.description, + type: response.type, + choices: response.choices, + } + }; + + constructChangeForm(fields, { + url: url, + method: 'PATCH', + title: options.title, + processResults: function(data, fields, opts) { + + switch (data.type) { + case 'boolean': + // Convert to boolean value + data.value = data.value.toString().toLowerCase() == 'true'; + break; + case 'integer': + // Convert to integer value + data.value = parseInt(data.value.toString()); + break; + default: + break; + } + + return data; + }, + processBeforeUpload: function(data) { + // Convert value to string + data.value = data.value.toString(); + + return data; + }, + onSuccess: function(response) { + + var setting = response.key; + + if (response.type == 'boolean') { + var enabled = response.value.toString().toLowerCase() == 'true'; + $(`#setting-value-${setting}`).prop('checked', enabled); + } else { + $(`#setting-value-${setting}`).html(response.value); + } + } + }); + }, + error: function(xhr) { + showApiError(xhr, url); + } + }); +} diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index d0640408be..15a74a9a71 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -61,7 +61,11 @@ function inventreeGet(url, filters={}, options={}) { }, error: function(xhr, ajaxOptions, thrownError) { console.error('Error on GET at ' + url); - console.error(thrownError); + + if (thrownError) { + console.error('Error: ' + thrownError); + } + if (options.error) { options.error({ error: thrownError @@ -174,7 +178,7 @@ function showApiError(xhr, url) { var title = null; var message = null; - switch (xhr.status) { + switch (xhr.status || 0) { // No response case 0: title = '{% trans "No Response" %}'; diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 2778983341..6be56d14f1 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -257,7 +257,7 @@ function barcodeDialog(title, options={}) { $(modal).modal({ backdrop: 'static', - keyboard: false, + keyboard: user_settings.FORMS_CLOSE_USING_ESCAPE, }); if (options.preShow) { diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 499fde9bec..bcbbfcc6d1 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -43,11 +43,18 @@ function buildFormFields() { } }, sales_order: { + icon: 'fa-truck', }, batch: {}, - target_date: {}, - take_from: {}, - destination: {}, + target_date: { + icon: 'fa-calendar-alt', + }, + take_from: { + icon: 'fa-sitemap', + }, + destination: { + icon: 'fa-sitemap', + }, link: { icon: 'fa-link', }, diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a86b64d0e2..2f25fef259 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -19,7 +19,6 @@ renderStockLocation, renderSupplierPart, renderUser, - showAlertDialog, showAlertOrCache, showApiError, */ @@ -199,14 +198,6 @@ function constructChangeForm(fields, options) { json: 'application/json', }, success: function(data) { - - // Push existing 'value' to each field - for (const field in data) { - - if (field in fields) { - fields[field].value = data[field]; - } - } // An optional function can be provided to process the returned results, // before they are rendered to the form @@ -219,6 +210,14 @@ function constructChangeForm(fields, options) { } } + // Push existing 'value' to each field + for (const field in data) { + + if (field in fields) { + fields[field].value = data[field]; + } + } + // Store the entire data object options.instance = data; @@ -347,10 +346,12 @@ function constructForm(url, options) { constructCreateForm(OPTIONS.actions.POST, options); } else { // User does not have permission to POST to the endpoint - showAlertDialog( - '{% trans "Action Prohibited" %}', - '{% trans "Create operation not allowed" %}' - ); + showMessage('{% trans "Action Prohibited" %}', { + style: 'danger', + details: '{% trans "Create operation not allowed" %}', + icon: 'fas fa-user-times', + }); + console.log(`'POST action unavailable at ${url}`); } break; @@ -360,10 +361,12 @@ function constructForm(url, options) { constructChangeForm(OPTIONS.actions.PUT, options); } else { // User does not have permission to PUT/PATCH to the endpoint - showAlertDialog( - '{% trans "Action Prohibited" %}', - '{% trans "Update operation not allowed" %}' - ); + showMessage('{% trans "Action Prohibited" %}', { + style: 'danger', + details: '{% trans "Update operation not allowed" %}', + icon: 'fas fa-user-times', + }); + console.log(`${options.method} action unavailable at ${url}`); } break; @@ -372,10 +375,12 @@ function constructForm(url, options) { constructDeleteForm(OPTIONS.actions.DELETE, options); } else { // User does not have permission to DELETE to the endpoint - showAlertDialog( - '{% trans "Action Prohibited" %}', - '{% trans "Delete operation not allowed" %}' - ); + showMessage('{% trans "Action Prohibited" %}', { + style: 'danger', + details: '{% trans "Delete operation not allowed" %}', + icon: 'fas fa-user-times', + }); + console.log(`DELETE action unavailable at ${url}`); } break; @@ -384,10 +389,12 @@ function constructForm(url, options) { // TODO? } else { // User does not have permission to GET to the endpoint - showAlertDialog( - '{% trans "Action Prohibited" %}', - '{% trans "View operation not allowed" %}' - ); + showMessage('{% trans "Action Prohibited" %}', { + style: 'danger', + details: '{% trans "View operation not allowed" %}', + icon: 'fas fa-user-times', + }); + console.log(`GET action unavailable at ${url}`); } break; @@ -717,6 +724,11 @@ function submitFormData(fields, options) { data = form_data; } + // Optionally pre-process the data before uploading to the server + if (options.processBeforeUpload) { + data = options.processBeforeUpload(data); + } + // Submit data upload_func( options.url, diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 742083bbe4..dc1adf8837 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -992,7 +992,7 @@ function loadPartTable(table, url, options={}) { } }); - var grid_view = inventreeLoad('part-grid-view') == 1; + var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1; $(table).inventreeTable({ url: url, @@ -1020,7 +1020,7 @@ function loadPartTable(table, url, options={}) { $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); } }, - buttons: [ + buttons: options.gridView ? [ { icon: 'fas fa-bars', attributes: { @@ -1053,7 +1053,7 @@ function loadPartTable(table, url, options={}) { ); } } - ], + ] : [], customView: function(data) { var html = ''; diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index c63031e3cb..cd2a2a0a56 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -12,9 +12,6 @@ - {% include "search_form.html" %} -
diff --git a/InvenTree/templates/qr_code.html b/InvenTree/templates/qr_code.html index 8964ef02be..a39847629d 100644 --- a/InvenTree/templates/qr_code.html +++ b/InvenTree/templates/qr_code.html @@ -3,7 +3,7 @@
{% if qr_data %} -
+
{% else %} diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 240d6aabc0..222f284add 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -7,15 +7,16 @@ from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include -from rest_framework import generics, permissions +from django_filters.rest_framework import DjangoFilterBackend + +from rest_framework import filters, generics, permissions from rest_framework.views import APIView from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework import status -from .serializers import UserSerializer, OwnerSerializer - -from .models import RuleSet, Owner, check_user_role +from users.models import RuleSet, Owner, check_user_role +from users.serializers import UserSerializer, OwnerSerializer class OwnerList(generics.ListAPIView): @@ -26,6 +27,37 @@ class OwnerList(generics.ListAPIView): queryset = Owner.objects.all() serializer_class = OwnerSerializer + def filter_queryset(self, queryset): + """ + Implement text search for the "owner" model. + + Note that an "owner" can be either a group, or a user, + so we cannot do a direct text search. + + A "hack" here is to post-process the queryset and simply + remove any values which do not match. + + It is not necessarily "efficient" to do it this way, + but until we determine a better way, this is what we have... + """ + + search_term = str(self.request.query_params.get('search', '')).lower() + + queryset = super().filter_queryset(queryset) + + if not search_term: + return queryset + + results = [] + + # Extract search term f + + for result in queryset.all(): + if search_term in result.name().lower(): + results.append(result) + + return results + class OwnerDetail(generics.RetrieveAPIView): """ @@ -96,6 +128,17 @@ class UserList(generics.ListAPIView): serializer_class = UserSerializer permission_classes = (permissions.IsAuthenticated,) + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + ] + + search_fields = [ + 'first_name', + 'last_name', + 'username', + ] + class GetAuthToken(APIView): """ Return authentication token for an authenticated user. """