mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge branch 'master' of https://github.com/inventree/InvenTree into plugin-2037
This commit is contained in:
		
							
								
								
									
										1
									
								
								.github/workflows/html.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/html.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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<pk>\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'), | ||||
|     url(r'^(?P<pk>\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'), | ||||
| ] | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -47,7 +47,7 @@ def get_next_build_number(): | ||||
|     """ | ||||
|  | ||||
|     if Build.objects.count() == 0: | ||||
|         return | ||||
|         return '0001' | ||||
|  | ||||
|     build = Build.objects.exclude(reference=None).last() | ||||
|  | ||||
| @@ -107,6 +107,21 @@ class Build(MPTTModel, ReferenceIndexingMixin): | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     @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() | ||||
|   | ||||
| @@ -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/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'), | ||||
|  | ||||
|     # User settings | ||||
|     url(r'^user/', include([ | ||||
|         # User Settings Detail | ||||
|         url(r'^(?P<pk>\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<pk>\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), | ||||
|  | ||||
|         # Global Settings List | ||||
|         url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), | ||||
|     ])), | ||||
|  | ||||
| ] | ||||
|   | ||||
| @@ -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): | ||||
|     """ | ||||
|   | ||||
| @@ -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', | ||||
|         ] | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| {% extends "modal_form.html" %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block pre_form_content %} | ||||
|  | ||||
| {{ block.super }} | ||||
| <!-- | ||||
|     <p> | ||||
|         <strong>{{ name }}</strong><br> | ||||
|         {{ description }}<br> | ||||
|         <em>{% trans "Current value" %}: {{ value }}</em> | ||||
|     </p> | ||||
| --> | ||||
| {% endblock %} | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
| @@ -242,6 +242,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): | ||||
|         help_text=_('Unique identifier field'), | ||||
|         default='', | ||||
|         required=False, | ||||
|         allow_null=True, | ||||
|         allow_blank=True, | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|  | ||||
| {% block thumbnail %} | ||||
| <img class='part-thumb' | ||||
| {% if order.customer.image %} | ||||
| {% if order.customer and order.customer.image %} | ||||
| src="{{ order.customer.image.url }}" | ||||
| {% else %} | ||||
| src="{% static 'img/blank_image.png' %}" | ||||
| @@ -106,11 +106,13 @@ src="{% static 'img/blank_image.png' %}" | ||||
|             {% endif %} | ||||
|         </td> | ||||
|     </tr> | ||||
|     {% if order.customer %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-building'></span></td> | ||||
|         <td>{% trans "Customer" %}</td> | ||||
|         <td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if order.customer_reference %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-hashtag'></span></td> | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -29,7 +29,7 @@ | ||||
|     <span class='fas fa-bell icon-green'></span> | ||||
| </button> | ||||
| {% else %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this category" %}'> | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to notifications for this category" %}'> | ||||
|     <span id='category-star-icon' class='fa fa-bell-slash'/> | ||||
| </button> | ||||
| {% endif %} | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
| {% endif %} | ||||
|  | ||||
| {% if starred_directly %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'> | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this part" %}'> | ||||
|     <span id='part-star-icon' class='fas fa-bell icon-green'/> | ||||
| </button> | ||||
| {% elif starred %} | ||||
| @@ -32,7 +32,7 @@ | ||||
|     <span class='fas fa-bell icon-green'></span> | ||||
| </button> | ||||
| {% else %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> | ||||
| <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to notifications for this part" %}'> | ||||
|     <span id='part-star-icon' class='fa fa-bell-slash'/> | ||||
| </button> | ||||
| {% endif %} | ||||
|   | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
| @@ -53,6 +53,12 @@ | ||||
| </div> | ||||
| <!-- Stock adjustment menu --> | ||||
| <!-- Check permissions and owner --> | ||||
|  | ||||
| {% 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 %} | ||||
|     <div class='btn-group'> | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -212,26 +212,37 @@ | ||||
|                 {% trans "Select language" %} | ||||
|             </label> | ||||
|             <div class='form-group input-group mb-3'> | ||||
|                 <select name="language" class="select form-control"> | ||||
|                 <select name="language" class="select form-control w-25"> | ||||
|                     {% 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  %} | ||||
|                         <option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}> | ||||
|                             {{ language.name_local }} ({{ lang_code }})  | ||||
|                             {% if lang_translated %} | ||||
|                                 {% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %} | ||||
|                             {% else %} | ||||
|                                 {% trans 'No translations available' %} | ||||
|                                 {% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %} | ||||
|                             {% endif %} | ||||
|                         </option> | ||||
|                         {% endif %} | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                 <div class='input-group-append'> | ||||
|                     <input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary"> | ||||
|                 </div> | ||||
|                 <p>{% trans "Some languages are not complete" %} | ||||
|                 {% if ALL_LANG %} | ||||
|                 . <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a> | ||||
|                 {% else %} | ||||
|                 and hidden. <a href="?alllang">{% trans "Show them too" %}</a> | ||||
|                 {% endif %} | ||||
|                 </p> | ||||
|             </div> | ||||
|     </form> | ||||
|     </div> | ||||
|   | ||||
| @@ -22,12 +22,12 @@ | ||||
|                                     <a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %} | ||||
|                                     {% inventree_is_development as dev %} | ||||
|                                     {% if dev %} | ||||
|                                     <span class='badge rounded-pill bg-primary'>{% trans "Development Version" %}</span> | ||||
|                                     <span class='badge badge-right rounded-pill bg-primary'>{% trans "Development Version" %}</span> | ||||
|                                     {% else %} | ||||
|                                     {% if up_to_date %} | ||||
|                                     <span class='badge rounded-pill bg-success'>{% trans "Up to Date" %}</span> | ||||
|                                     <span class='badge badge-right rounded-pill bg-success'>{% trans "Up to Date" %}</span> | ||||
|                                     {% else %} | ||||
|                                     <span class='badge rounded-pill bg-info'>{% trans "Update Available" %}</span> | ||||
|                                     <span class='badge badge-right rounded-pill bg-info'>{% trans "Update Available" %}</span> | ||||
|                                     {% endif %} | ||||
|                                     {% endif %} | ||||
|                                 </td> | ||||
|   | ||||
| @@ -71,8 +71,11 @@ | ||||
|                     {% include "spacer.html" %} | ||||
|                     <span class='float-right'><h3>{% inventree_title %}</h3></span> | ||||
|                 </div> | ||||
|             </div> | ||||
|                 <div class='container-fluid'> | ||||
|                     <hr> | ||||
|                 <div class='container-fluid'>{% block content %}{% endblock %}</div> | ||||
|                     {% block content %} | ||||
|                     {% endblock %} | ||||
|                 </div> | ||||
|         </div> | ||||
|         </div> | ||||
|   | ||||
| @@ -32,6 +32,7 @@ for a account and sign in below:{% endblocktrans %}</p> | ||||
|   <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" /> | ||||
|   {% endif %} | ||||
|  | ||||
|   <hr> | ||||
|   <div class="btn-group float-right" role="group"> | ||||
|     <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button> | ||||
|   </div> | ||||
|   | ||||
| @@ -14,6 +14,7 @@ | ||||
|   {% if redirect_field_value %} | ||||
|   <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/> | ||||
|   {% endif %} | ||||
|   <hr> | ||||
|   <div class='btn-group float-right' role='group'> | ||||
|     <a type='button' class='btn btn-secondary' href='{% url "index" %}'><span class='fas fa-undo-alt'></span> {% trans "Back to Site" %}</a> | ||||
|     <button type="submit" class="btn btn-danger btn-block">{% trans 'Sign Out' %}</button> | ||||
|   | ||||
| @@ -83,7 +83,7 @@ | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         <main class='col ps-md-2 pt-2'> | ||||
|         <main class='col ps-md-2 pt-2 pe-2'> | ||||
|  | ||||
|             {% block alerts %} | ||||
|             <div class='notification-area' id='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 %} | ||||
| }); | ||||
|  | ||||
| </script> | ||||
|   | ||||
| @@ -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); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|   | ||||
| @@ -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" %}'; | ||||
|   | ||||
| @@ -257,7 +257,7 @@ function barcodeDialog(title, options={}) { | ||||
|  | ||||
|     $(modal).modal({ | ||||
|         backdrop: 'static', | ||||
|         keyboard: false, | ||||
|         keyboard: user_settings.FORMS_CLOSE_USING_ESCAPE, | ||||
|     }); | ||||
|  | ||||
|     if (options.preShow) { | ||||
|   | ||||
| @@ -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', | ||||
|         }, | ||||
|   | ||||
| @@ -19,7 +19,6 @@ | ||||
|     renderStockLocation, | ||||
|     renderSupplierPart, | ||||
|     renderUser, | ||||
|     showAlertDialog, | ||||
|     showAlertOrCache, | ||||
|     showApiError, | ||||
| */ | ||||
| @@ -200,14 +199,6 @@ function constructChangeForm(fields, options) { | ||||
|         }, | ||||
|         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 | ||||
|             if (options.processResults) { | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 = ''; | ||||
|   | ||||
| @@ -12,9 +12,6 @@ | ||||
|     <div class="navbar-header clearfix content-heading"> | ||||
|       <a class="navbar-brand" id='logo' href="{% url 'index' %}" style="padding-top: 7px; padding-bottom: 5px;"><img src="{% static 'img/inventree.png' %}" width="32" height="32" style="display:block; margin: auto;"/></a> | ||||
|     </div> | ||||
|     <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-objects" aria-label="Toggle navigation"> | ||||
|       <span class="navbar-toggler-icon"></span> | ||||
|     </button> | ||||
|     <div class="navbar-collapse collapse" id="navbar-objects"> | ||||
|       <ul class="navbar-nav"> | ||||
|         {% if roles.part.view %} | ||||
| @@ -84,19 +81,24 @@ | ||||
|       </ul> | ||||
|     </div> | ||||
|     {% include "search_form.html" %} | ||||
|     <ul class='navbar-nav'> | ||||
|     <ul class='navbar-nav flex-row'> | ||||
|       {% if barcodes %} | ||||
|       <li id='navbar-barcode-li'> | ||||
|       <li class='nav-item' id='navbar-barcode-li'> | ||||
|         <button id='barcode-scan' class='btn btn-secondary' title='{% trans "Scan Barcode" %}'> | ||||
|           <span class='fas fa-qrcode'></span> | ||||
|         </button> | ||||
|       </li> | ||||
|       {% endif %} | ||||
|       <li class='nav-item' id='navbar-barcode-li'> | ||||
|         <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-objects" aria-label="Toggle navigation"> | ||||
|           <span class="navbar-toggler-icon"></span> | ||||
|         </button> | ||||
|       </li> | ||||
|       <li class='nav-item dropdown'> | ||||
|         <a class='nav-link dropdown-toggle' href='#' id='userMenuDropdown' role='button' data-bs-toggle='dropdown'> | ||||
|           <span class='fas fa-user'></span> <strong>{{ user.get_username }}</strong> | ||||
|         </a> | ||||
|         <ul class='dropdown-menu dropdown-menu-end'> | ||||
|         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> | ||||
|           {% if user.is_authenticated %} | ||||
|           {% if user.is_staff %} | ||||
|           <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li> | ||||
| @@ -130,7 +132,6 @@ | ||||
|         </ul> | ||||
|       </li> | ||||
|     </ul> | ||||
|  | ||||
|   </div> | ||||
| </nav> | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| <div class='container' style='width: 80%;'> | ||||
|     {% if qr_data %} | ||||
|     <div class='qr-container'> | ||||
|     <div class='d-flex justify-content-center'> | ||||
|         <img src="{% qrcode qr_data %}"> | ||||
|     </div> | ||||
|     {% else %} | ||||
|   | ||||
| @@ -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. """ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user