This commit is contained in:
Matthias 2021-11-10 00:30:21 +01:00
commit 2835022330
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
37 changed files with 628 additions and 391 deletions

View File

@ -43,7 +43,6 @@ jobs:
run: | run: |
npm install markuplint npm install markuplint
npx markuplint InvenTree/build/templates/build/*.html npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/common/templates/common/*.html
npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html npx markuplint InvenTree/part/templates/part/*.html

View File

@ -108,6 +108,13 @@ class InvenTreeMetadata(SimpleMetadata):
model_fields = model_meta.get_field_info(model_class) 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 # Iterate through simple fields
for name, field in model_fields.fields.items(): for name, field in model_fields.fields.items():
@ -123,6 +130,9 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info[name]['default'] = default serializer_info[name]['default'] = default
elif name in model_default_values:
serializer_info[name]['default'] = model_default_values[name]
# Iterate through relations # Iterate through relations
for name, relation in model_fields.relations.items(): 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'): 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 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: except AttributeError:
pass pass

View File

@ -34,8 +34,7 @@
} }
.login-header { .login-header {
padding-right: 30px; margin-right: 5px;
margin-right: 30px;
} }
.login-container input { .login-container input {
@ -125,15 +124,14 @@
align-content: center; align-content: center;
} }
.qr-container {
width: 100%;
align-content: center;
object-fit: fill;
}
.navbar { .navbar {
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
background-color: var(--secondary-color); background-color: var(--secondary-color);
box-shadow: 0px 5px 5px rgb(0 0 0 / 5%);
}
.inventree-navbar-menu {
position: absolute !important;
} }
.navbar-brand { .navbar-brand {
@ -544,6 +542,7 @@
.inventree-body { .inventree-body {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
padding-right: 0;
} }
.inventree-pre-content { .inventree-pre-content {
@ -829,11 +828,12 @@ input[type="submit"] {
color: var(--bs-body-color); color: var(--bs-body-color);
background-color: var(--secondary-color); background-color: var(--secondary-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
box-shadow: 0px 5px 5px rgb(0 0 0 / 5%);
} }
.panel { .panel {
box-shadow: 2px 2px #DDD; box-shadow: 2px 2px #DDD;
margin-bottom: 20px; margin-bottom: .75rem;
background-color: #fff; background-color: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;
} }

View File

@ -42,8 +42,6 @@ from .views import CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit, UserSettingEdit
from .api import InfoView, NotFoundView from .api import InfoView, NotFoundView
from .api import ActionPluginView from .api import ActionPluginView
@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [ apipatterns = [
url(r'^barcode/', include(barcode_api_urls)), url(r'^barcode/', include(barcode_api_urls)),
url(r'^common/', include(common_api_urls)), url(r'^settings/', include(common_api_urls)),
url(r'^part/', include(part_api_urls)), url(r'^part/', include(part_api_urls)),
url(r'^bom/', include(bom_api_urls)), url(r'^bom/', include(bom_api_urls)),
url(r'^company/', include(company_api_urls)), url(r'^company/', include(company_api_urls)),
@ -85,9 +83,6 @@ settings_urls = [
url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'), 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 # Catch any other urls
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
] ]

View File

@ -12,11 +12,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # 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 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 v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs - Adds API endpoint for completing build order outputs

View File

@ -47,7 +47,7 @@ def get_next_build_number():
""" """
if Build.objects.count() == 0: if Build.objects.count() == 0:
return return '0001'
build = Build.objects.exclude(reference=None).last() 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): def save(self, *args, **kwargs):
self.rebuild_reference_field() self.rebuild_reference_field()

View File

@ -5,5 +5,149 @@ Provides a JSON API for common components.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
common_api_urls = [ from django.conf.urls import url, include
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, permissions
import common.models
import common.serializers
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 = [
# 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'),
]))
] ]

View File

@ -34,6 +34,19 @@ import logging
logger = logging.getLogger('inventree') 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): class BaseInvenTreeSetting(models.Model):
""" """
An base InvenTreeSetting object is a key:value pair used for storing An base InvenTreeSetting object is a key:value pair used for storing
@ -45,6 +58,16 @@ class BaseInvenTreeSetting(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs):
"""
Enforce validation and clean before saving
"""
self.clean()
self.validate_unique()
super().save()
@classmethod @classmethod
def allValues(cls, user=None): def allValues(cls, user=None):
""" """
@ -343,6 +366,11 @@ class BaseInvenTreeSetting(models.Model):
except (ValueError): except (ValueError):
raise ValidationError(_('Must be an integer value')) 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: if validator is not None:
self.run_validator(validator) self.run_validator(validator)
@ -409,6 +437,18 @@ class BaseInvenTreeSetting(models.Model):
return self.__class__.get_setting_choices(self.key) 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): def is_bool(self):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
@ -427,6 +467,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) 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 @classmethod
def validator_is_bool(cls, validator): def validator_is_bool(cls, validator):
@ -531,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_BASE_URL': { 'INVENTREE_BASE_URL': {
'name': _('Base URL'), 'name': _('Base URL'),
'description': _('Base URL for server instance'), 'description': _('Base URL for server instance'),
'validator': URLValidator(), 'validator': EmptyURLValidator(),
'default': '', 'default': '',
}, },
@ -850,7 +904,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'SIGNUP_GROUP': { 'SIGNUP_GROUP': {
'name': _('Group on signup'), 'name': _('Group on signup'),
'description': _('Group new user are asigned on registration'), 'description': _('Group to which new users are assigned on registration'),
'default': '', 'default': '',
'choices': settings_group_options 'choices': settings_group_options
}, },
@ -873,6 +927,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
help_text=_('Settings key (must be unique - case insensitive'), 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): class InvenTreeUserSetting(BaseInvenTreeSetting):
""" """
@ -1083,6 +1145,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'user__id': kwargs['user'].id '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): class PriceBreak(models.Model):
""" """

View File

@ -1,3 +1,85 @@
""" """
JSON serializers for common components 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',
]

View File

@ -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 %}

View File

@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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)

View File

@ -8,138 +8,18 @@ from __future__ import unicode_literals
import os import os
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select
from django.conf import settings from django.conf import settings
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView from formtools.wizard.views import SessionWizardView
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from InvenTree.views import AjaxUpdateView, AjaxView from InvenTree.views import AjaxView
from InvenTree.helpers import str2bool
from . import models
from . import forms from . import forms
from .files import FileManager 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): class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form """ Setup basic methods of multi-step form

View File

@ -37,7 +37,7 @@ def get_next_po_number():
""" """
if PurchaseOrder.objects.count() == 0: if PurchaseOrder.objects.count() == 0:
return return '0001'
order = PurchaseOrder.objects.exclude(reference=None).last() order = PurchaseOrder.objects.exclude(reference=None).last()
@ -66,7 +66,7 @@ def get_next_so_number():
""" """
if SalesOrder.objects.count() == 0: if SalesOrder.objects.count() == 0:
return return '0001'
order = SalesOrder.objects.exclude(reference=None).last() order = SalesOrder.objects.exclude(reference=None).last()

View File

@ -242,6 +242,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
help_text=_('Unique identifier field'), help_text=_('Unique identifier field'),
default='', default='',
required=False, required=False,
allow_null=True,
allow_blank=True, allow_blank=True,
) )

View File

@ -16,7 +16,7 @@
{% block thumbnail %} {% block thumbnail %}
<img class='part-thumb' <img class='part-thumb'
{% if order.customer.image %} {% if order.customer and order.customer.image %}
src="{{ order.customer.image.url }}" src="{{ order.customer.image.url }}"
{% else %} {% else %}
src="{% static 'img/blank_image.png' %}" src="{% static 'img/blank_image.png' %}"
@ -106,11 +106,13 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if order.customer %}
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td> <td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td> <td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %}
{% if order.customer_reference %} {% if order.customer_reference %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>

View File

@ -349,6 +349,31 @@ class PurchaseOrderReceiveTest(OrderTest):
# No new stock items have been created # No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count()) 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): def test_invalid_barcodes(self):
""" """
Tests for checking in items with invalid barcodes: Tests for checking in items with invalid barcodes:

View File

@ -29,7 +29,7 @@
<span class='fas fa-bell icon-green'></span> <span class='fas fa-bell icon-green'></span>
</button> </button>
{% else %} {% 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'/> <span id='category-star-icon' class='fa fa-bell-slash'/>
</button> </button>
{% endif %} {% endif %}

View File

@ -24,7 +24,7 @@
{% endif %} {% endif %}
{% if starred_directly %} {% 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'/> <span id='part-star-icon' class='fas fa-bell icon-green'/>
</button> </button>
{% elif starred %} {% elif starred %}
@ -32,7 +32,7 @@
<span class='fas fa-bell icon-green'></span> <span class='fas fa-bell icon-green'></span>
</button> </button>
{% else %} {% 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'/> <span id='part-star-icon' class='fa fa-bell-slash'/>
</button> </button>
{% endif %} {% endif %}

View File

@ -257,7 +257,6 @@ class ReportPrintMixin:
pages = [] pages = []
try: try:
pdf = outputs[0].get_document().copy(pages).write_pdf()
if len(outputs) > 1: if len(outputs) > 1:
# If more than one output is generated, merge them into a single file # If more than one output is generated, merge them into a single file
@ -265,6 +264,8 @@ class ReportPrintMixin:
doc = output.get_document() doc = output.get_document()
for page in doc.pages: for page in doc.pages:
pages.append(page) pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
else: else:
pdf = outputs[0].get_document().write_pdf() pdf = outputs[0].get_document().write_pdf()

View File

@ -53,6 +53,12 @@
</div> </div>
<!-- Stock adjustment menu --> <!-- Stock adjustment menu -->
<!-- Check permissions and owner --> <!-- 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 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 %} {% if roles.stock.change and not item.is_building %}
<div class='btn-group'> <div class='btn-group'>

View File

@ -50,26 +50,17 @@
$('table').find('.btn-edit-setting').click(function() { $('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting'); var setting = $(this).attr('setting');
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var url = `/settings/${pk}/edit/`;
var is_global = true;
if ($(this).attr('user')){ if ($(this).attr('user')){
url += `user/`; is_global = false;
} }
launchModalForm( editSetting(pk, {
url, global: is_global,
{ title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
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);
}
}
}
);
}); });
$("#edit-user").on('click', function() { $("#edit-user").on('click', function() {

View File

@ -250,26 +250,37 @@
{% trans "Select language" %} {% trans "Select language" %}
</label> </label>
<div class='form-group input-group mb-3'> <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_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %} {% get_available_languages as LANGUAGES %}
{% get_language_info_list for 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 %} {% for language in languages %}
{% define language.code as lang_code %} {% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %} {% 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 %}> <option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }}) {{ language.name_local }} ({{ lang_code }})
{% if lang_translated %} {% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %} {% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %} {% else %}
{% trans 'No translations available' %} {% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %} {% endif %}
</option> </option>
{% endif %}
{% endfor %} {% endfor %}
</select> </select>
<div class='input-group-append'> <div class='input-group-append'>
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary"> <input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div> </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> </div>
</form> </form>
</div> </div>

View File

@ -22,12 +22,12 @@
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %} <a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
{% inventree_is_development as dev %} {% inventree_is_development as dev %}
{% if 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 %} {% else %}
{% if up_to_date %} {% 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 %} {% 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 %}
{% endif %} {% endif %}
</td> </td>

View File

@ -71,8 +71,11 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<span class='float-right'><h3>{% inventree_title %}</h3></span> <span class='float-right'><h3>{% inventree_title %}</h3></span>
</div> </div>
</div>
<div class='container-fluid'>
<hr> <hr>
<div class='container-fluid'>{% block content %}{% endblock %}</div> {% block content %}
{% endblock %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -32,6 +32,7 @@ for a account and sign in below:{% endblocktrans %}</p>
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" /> <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
{% endif %} {% endif %}
<hr>
<div class="btn-group float-right" role="group"> <div class="btn-group float-right" role="group">
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button> <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
</div> </div>

View File

@ -14,6 +14,7 @@
{% if redirect_field_value %} {% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/> <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
{% endif %} {% endif %}
<hr>
<div class='btn-group float-right' role='group'> <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> <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> <button type="submit" class="btn btn-danger btn-block">{% trans 'Sign Out' %}</button>

View File

@ -83,7 +83,7 @@
</div> </div>
</div> </div>
</div> </div>
<main class='col ps-md-2 pt-2'> <main class='col ps-md-2 pt-2 pe-2'>
{% block alerts %} {% block alerts %}
<div class='notification-area' id='alerts'> <div class='notification-area' id='alerts'>
@ -190,6 +190,18 @@ $(document).ready(function () {
{% endif %} {% endif %}
moment.locale('{{ request.LANGUAGE_CODE }}'); moment.locale('{{ request.LANGUAGE_CODE }}');
// Account notifications
{% if messages %}
{% for message in messages %}
showMessage(
'{{ message }}',
{
style: 'info',
}
);
{% endfor %}
{% endif %}
}); });
</script> </script>

View File

@ -1,6 +1,7 @@
{% load inventree_extras %} {% load inventree_extras %}
/* exported /* exported
editSetting,
user_settings, user_settings,
global_settings, global_settings,
*/ */
@ -18,3 +19,83 @@ const global_settings = {
{{ key }}: {% primitive_to_javascript value %}, {{ key }}: {% primitive_to_javascript value %},
{% endfor %} {% 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);
}
});
}

View File

@ -61,7 +61,11 @@ function inventreeGet(url, filters={}, options={}) {
}, },
error: function(xhr, ajaxOptions, thrownError) { error: function(xhr, ajaxOptions, thrownError) {
console.error('Error on GET at ' + url); console.error('Error on GET at ' + url);
console.error(thrownError);
if (thrownError) {
console.error('Error: ' + thrownError);
}
if (options.error) { if (options.error) {
options.error({ options.error({
error: thrownError error: thrownError
@ -174,7 +178,7 @@ function showApiError(xhr, url) {
var title = null; var title = null;
var message = null; var message = null;
switch (xhr.status) { switch (xhr.status || 0) {
// No response // No response
case 0: case 0:
title = '{% trans "No Response" %}'; title = '{% trans "No Response" %}';

View File

@ -257,7 +257,7 @@ function barcodeDialog(title, options={}) {
$(modal).modal({ $(modal).modal({
backdrop: 'static', backdrop: 'static',
keyboard: false, keyboard: user_settings.FORMS_CLOSE_USING_ESCAPE,
}); });
if (options.preShow) { if (options.preShow) {

View File

@ -43,11 +43,18 @@ function buildFormFields() {
} }
}, },
sales_order: { sales_order: {
icon: 'fa-truck',
}, },
batch: {}, batch: {},
target_date: {}, target_date: {
take_from: {}, icon: 'fa-calendar-alt',
destination: {}, },
take_from: {
icon: 'fa-sitemap',
},
destination: {
icon: 'fa-sitemap',
},
link: { link: {
icon: 'fa-link', icon: 'fa-link',
}, },

View File

@ -19,7 +19,6 @@
renderStockLocation, renderStockLocation,
renderSupplierPart, renderSupplierPart,
renderUser, renderUser,
showAlertDialog,
showAlertOrCache, showAlertOrCache,
showApiError, showApiError,
*/ */
@ -200,14 +199,6 @@ function constructChangeForm(fields, options) {
}, },
success: function(data) { 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, // An optional function can be provided to process the returned results,
// before they are rendered to the form // before they are rendered to the form
if (options.processResults) { 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 // Store the entire data object
options.instance = data; options.instance = data;
@ -347,10 +346,12 @@ function constructForm(url, options) {
constructCreateForm(OPTIONS.actions.POST, options); constructCreateForm(OPTIONS.actions.POST, options);
} else { } else {
// User does not have permission to POST to the endpoint // User does not have permission to POST to the endpoint
showAlertDialog( showMessage('{% trans "Action Prohibited" %}', {
'{% trans "Action Prohibited" %}', style: 'danger',
'{% trans "Create operation not allowed" %}' details: '{% trans "Create operation not allowed" %}',
); icon: 'fas fa-user-times',
});
console.log(`'POST action unavailable at ${url}`); console.log(`'POST action unavailable at ${url}`);
} }
break; break;
@ -360,10 +361,12 @@ function constructForm(url, options) {
constructChangeForm(OPTIONS.actions.PUT, options); constructChangeForm(OPTIONS.actions.PUT, options);
} else { } else {
// User does not have permission to PUT/PATCH to the endpoint // User does not have permission to PUT/PATCH to the endpoint
showAlertDialog( showMessage('{% trans "Action Prohibited" %}', {
'{% trans "Action Prohibited" %}', style: 'danger',
'{% trans "Update operation not allowed" %}' details: '{% trans "Update operation not allowed" %}',
); icon: 'fas fa-user-times',
});
console.log(`${options.method} action unavailable at ${url}`); console.log(`${options.method} action unavailable at ${url}`);
} }
break; break;
@ -372,10 +375,12 @@ function constructForm(url, options) {
constructDeleteForm(OPTIONS.actions.DELETE, options); constructDeleteForm(OPTIONS.actions.DELETE, options);
} else { } else {
// User does not have permission to DELETE to the endpoint // User does not have permission to DELETE to the endpoint
showAlertDialog( showMessage('{% trans "Action Prohibited" %}', {
'{% trans "Action Prohibited" %}', style: 'danger',
'{% trans "Delete operation not allowed" %}' details: '{% trans "Delete operation not allowed" %}',
); icon: 'fas fa-user-times',
});
console.log(`DELETE action unavailable at ${url}`); console.log(`DELETE action unavailable at ${url}`);
} }
break; break;
@ -384,10 +389,12 @@ function constructForm(url, options) {
// TODO? // TODO?
} else { } else {
// User does not have permission to GET to the endpoint // User does not have permission to GET to the endpoint
showAlertDialog( showMessage('{% trans "Action Prohibited" %}', {
'{% trans "Action Prohibited" %}', style: 'danger',
'{% trans "View operation not allowed" %}' details: '{% trans "View operation not allowed" %}',
); icon: 'fas fa-user-times',
});
console.log(`GET action unavailable at ${url}`); console.log(`GET action unavailable at ${url}`);
} }
break; break;
@ -717,6 +724,11 @@ function submitFormData(fields, options) {
data = form_data; data = form_data;
} }
// Optionally pre-process the data before uploading to the server
if (options.processBeforeUpload) {
data = options.processBeforeUpload(data);
}
// Submit data // Submit data
upload_func( upload_func(
options.url, options.url,

View File

@ -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({ $(table).inventreeTable({
url: url, url: url,
@ -1020,7 +1020,7 @@ function loadPartTable(table, url, options={}) {
$('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
} }
}, },
buttons: [ buttons: options.gridView ? [
{ {
icon: 'fas fa-bars', icon: 'fas fa-bars',
attributes: { attributes: {
@ -1053,7 +1053,7 @@ function loadPartTable(table, url, options={}) {
); );
} }
} }
], ] : [],
customView: function(data) { customView: function(data) {
var html = ''; var html = '';

View File

@ -10,9 +10,6 @@
<div class="navbar-header clearfix content-heading"> <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> <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> </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"> <div class="navbar-collapse collapse" id="navbar-objects">
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if roles.part.view %} {% if roles.part.view %}
@ -62,19 +59,24 @@
</ul> </ul>
</div> </div>
{% include "search_form.html" %} {% include "search_form.html" %}
<ul class='navbar-nav'> <ul class='navbar-nav flex-row'>
{% if barcodes %} {% 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" %}'> <button id='barcode-scan' class='btn btn-secondary' title='{% trans "Scan Barcode" %}'>
<span class='fas fa-qrcode'></span> <span class='fas fa-qrcode'></span>
</button> </button>
</li> </li>
{% endif %} {% 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'> <li class='nav-item dropdown'>
<a class='nav-link dropdown-toggle' href='#' id='userMenuDropdown' role='button' data-bs-toggle='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> <span class='fas fa-user'></span> <strong>{{ user.get_username }}</strong>
</a> </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_authenticated %}
{% if user.is_staff %} {% if user.is_staff %}
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li> <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li>
@ -108,7 +110,6 @@
</ul> </ul>
</li> </li>
</ul> </ul>
</div> </div>
</nav> </nav>

View File

@ -3,7 +3,7 @@
<div class='container' style='width: 80%;'> <div class='container' style='width: 80%;'>
{% if qr_data %} {% if qr_data %}
<div class='qr-container'> <div class='d-flex justify-content-center'>
<img src="{% qrcode qr_data %}"> <img src="{% qrcode qr_data %}">
</div> </div>
{% else %} {% else %}

View File

@ -7,15 +7,16 @@ from django.core.exceptions import ObjectDoesNotExist
from django.conf.urls import url, include 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.views import APIView
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from .serializers import UserSerializer, OwnerSerializer from users.models import RuleSet, Owner, check_user_role
from users.serializers import UserSerializer, OwnerSerializer
from .models import RuleSet, Owner, check_user_role
class OwnerList(generics.ListAPIView): class OwnerList(generics.ListAPIView):
@ -26,6 +27,37 @@ class OwnerList(generics.ListAPIView):
queryset = Owner.objects.all() queryset = Owner.objects.all()
serializer_class = OwnerSerializer 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): class OwnerDetail(generics.RetrieveAPIView):
""" """
@ -96,6 +128,17 @@ class UserList(generics.ListAPIView):
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
]
search_fields = [
'first_name',
'last_name',
'username',
]
class GetAuthToken(APIView): class GetAuthToken(APIView):
""" Return authentication token for an authenticated user. """ """ Return authentication token for an authenticated user. """