2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 13:58:47 +00:00

Merge remote-tracking branch 'inventree/master' into locate-mixin

# Conflicts:
#	InvenTree/InvenTree/api_version.py
#	InvenTree/InvenTree/urls.py
This commit is contained in:
Oliver Walters 2022-05-15 23:44:07 +10:00
commit fa954b3812
66 changed files with 17711 additions and 17204 deletions

View File

@ -124,6 +124,16 @@ jobs:
env: env:
wrapper_name: inventree-python wrapper_name: inventree-python
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps: steps:
- name: Checkout Code - name: Checkout Code
@ -140,13 +150,14 @@ jobs:
git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start Server - name: Start Server
run: | run: |
invoke import-records -f ./${{ env.wrapper_name }}/test/test_data.json invoke delete-data -f
invoke server -a 127.0.0.1:8000 & invoke import-fixtures
sleep ${{ env.server_start_sleep }} invoke server -a 127.0.0.1:12345 &
- name: Run Tests - name: Run Tests
run: | run: |
cd ${{ env.wrapper_name }} cd ${{ env.wrapper_name }}
invoke test invoke check-server
coverage run -m unittest discover -s test/
coverage: coverage:
name: Sqlite / coverage name: Sqlite / coverage
@ -196,6 +207,7 @@ jobs:
name: Postgres name: Postgres
needs: ['javascript', 'html'] needs: ['javascript', 'html']
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push'
env: env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql INVENTREE_DB_ENGINE: django.db.backends.postgresql
@ -253,6 +265,8 @@ jobs:
name: MySql name: MySql
needs: ['javascript', 'html'] needs: ['javascript', 'html']
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push'
env: env:
# Database backend configuration # Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql INVENTREE_DB_ENGINE: django.db.backends.mysql

View File

@ -66,6 +66,44 @@ class NotFoundView(AjaxView):
return JsonResponse(data, status=404) return JsonResponse(data, status=404)
class APIDownloadMixin:
"""
Mixin for enabling a LIST endpoint to be downloaded a file.
To download the data, add the ?export=<fmt> to the query string.
The implementing class must provided a download_queryset method,
e.g.
def download_queryset(self, queryset, export_format):
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
"""
def get(self, request, *args, **kwargs):
export_format = request.query_params.get('export', None)
if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']:
queryset = self.filter_queryset(self.get_queryset())
return self.download_queryset(queryset, export_format)
else:
# Default to the parent class implementation
return super().get(request, *args, **kwargs)
def download_queryset(self, queryset, export_format):
raise NotImplementedError("download_queryset method not implemented!")
class AttachmentMixin: class AttachmentMixin:
""" """
Mixin for creating attachment objects, Mixin for creating attachment objects,

View File

@ -4,16 +4,25 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 47 INVENTREE_API_VERSION = 49
""" """
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
v47 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957 v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
- Allows filtering of plugin list by 'active' status - Allows filtering of plugin list by 'active' status
- Allows filtering of plugin list by 'mixin' support - Allows filtering of plugin list by 'mixin' support
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins) - Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
- Adds "export to file" functionality for PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint
- Adds "export to file" functionality for BuildOrder API endpoint
v47 -> 2022-05-10 : https://github.com/inventree/InvenTree/pull/2964
- Fixes barcode API error response when scanning a StockItem which does not exist
- Fixes barcode API error response when scanning a StockLocation which does not exist
v46 -> 2022-05-09 v46 -> 2022-05-09
- Fixes read permissions on settings API - Fixes read permissions on settings API
- Allows non-staff users to read global settings via the API - Allows non-staff users to read global settings via the API

View File

@ -190,8 +190,11 @@ class InvenTreeConfig(AppConfig):
user = get_user_model() user = get_user_model()
try: try:
with transaction.atomic(): with transaction.atomic():
new_user = user.objects.create_superuser(add_user, add_email, add_password) if user.objects.filter(username=add_user).exists():
logger.info(f'User {str(new_user)} was created!') logger.info(f"User {add_user} already exists - skipping creation")
else:
new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e: except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
if settings.TESTING_ENV: if settings.TESTING_ENV:

View File

@ -1,9 +1,11 @@
from django.shortcuts import HttpResponseRedirect # -*- coding: utf-8 -*-
from django.urls import reverse_lazy, Resolver404
from django.shortcuts import redirect
from django.urls import include, re_path
from django.conf import settings from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy, Resolver404
from django.urls import include, re_path
import logging import logging
@ -68,10 +70,6 @@ class AuthRequiredMiddleware(object):
# No authorization was found for the request # No authorization was found for the request
if not authorized: if not authorized:
# A logout request will redirect the user to the login screen
if request.path_info == reverse_lazy('account_logout'):
return HttpResponseRedirect(reverse_lazy('account_login'))
path = request.path_info path = request.path_info
# List of URL endpoints we *do not* want to redirect to # List of URL endpoints we *do not* want to redirect to
@ -82,11 +80,23 @@ class AuthRequiredMiddleware(object):
reverse_lazy('admin:logout'), reverse_lazy('admin:logout'),
] ]
if path not in urls and not path.startswith('/api/'): # Do not redirect requests to any of these paths
paths_ignore = [
'/api/',
'/js/',
'/media/',
'/static/',
]
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
# Save the 'next' parameter to pass through to the login view # Save the 'next' parameter to pass through to the login view
return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path)) return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path))
else:
# Return a 401 (Unauthorized) response code for this request
return HttpResponse('Unauthorized', status=401)
response = self.get_response(request) response = self.get_response(request)
return response return response

View File

@ -900,7 +900,7 @@ PLUGINS_ENABLED = _is_true(get_setting(
PLUGIN_FILE = get_plugin_file() PLUGIN_FILE = get_plugin_file()
# Plugin Directories (local plugins will be loaded from these directories) # Plugin Directories (local plugins will be loaded from these directories)
PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ] PLUGIN_DIRS = ['plugin.builtin', ]
if not TESTING: if not TESTING:
# load local deploy directory in prod # load local deploy directory in prod

View File

@ -0,0 +1,66 @@
"""Tests for middleware functions"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.urls import reverse
class MiddlewareTests(TestCase):
"""Test for middleware functions"""
def check_path(self, url, code=200, **kwargs):
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
self.assertEqual(response.status_code, code)
return response
def setUp(self):
super().setUp()
# Create a user
user = get_user_model()
self.user = user.objects.create_user(username='username', email='user@email.com', password='password')
self.client.login(username='username', password='password')
def test_AuthRequiredMiddleware(self):
"""Test the auth middleware"""
# test that /api/ routes go through
self.check_path(reverse('api-inventree-info'))
# logout
self.client.logout()
# check that account things go through
self.check_path(reverse('account_login'))
# logout goes diretly to login
self.check_path(reverse('account_logout'))
# check that frontend code is redirected to login
response = self.check_path(reverse('stats'), 302)
self.assertEqual(response.url, '/accounts/login/?next=/stats/')
# check that a 401 is raised
self.check_path(reverse('settings.js'), 401)
def test_token_auth(self):
"""Test auth with token auth"""
# get token
response = self.client.get(reverse('api-token'), format='json', data={})
token = response.data['token']
# logout
self.client.logout()
# this should raise a 401
self.check_path(reverse('settings.js'), 401)
# request with token
self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {token}')
# Request with broken token
self.check_path(reverse('settings.js'), 401, HTTP_Authorization='Token abcd123')
# should still fail without token
self.check_path(reverse('settings.js'), 401)

View File

@ -18,7 +18,6 @@ from build.urls import build_urls
from order.urls import order_urls from order.urls import order_urls
from plugin.urls import get_plugin_urls from plugin.urls import get_plugin_urls
from barcodes.api import barcode_api_urls
from common.api import common_api_urls, settings_api_urls from common.api import common_api_urls, settings_api_urls
from part.api import part_api_urls, bom_api_urls from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls from company.api import company_api_urls
@ -28,6 +27,7 @@ from order.api import order_api_urls
from label.api import label_api_urls from label.api import label_api_urls
from report.api import report_api_urls from report.api import report_api_urls
from plugin.api import plugin_api_urls from plugin.api import plugin_api_urls
from plugin.barcode import barcode_api_urls
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
@ -59,7 +59,6 @@ if settings.PLUGINS_ENABLED:
) )
apipatterns += [ apipatterns += [
re_path(r'^barcode/', include(barcode_api_urls)),
re_path(r'^settings/', include(settings_api_urls)), re_path(r'^settings/', include(settings_api_urls)),
re_path(r'^part/', include(part_api_urls)), re_path(r'^part/', include(part_api_urls)),
re_path(r'^bom/', include(bom_api_urls)), re_path(r'^bom/', include(bom_api_urls)),
@ -75,6 +74,7 @@ apipatterns += [
# Plugin endpoints # Plugin endpoints
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)),
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'), re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
# Webhook enpoint # Webhook enpoint

View File

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
import warnings
import plugin.builtin.barcode.mixins as mixin
import plugin.integration
hash_barcode = mixin.hash_barcode
class BarcodePlugin(mixin.BarcodeMixin, plugin.integration.IntegrationPluginBase):
"""
Legacy barcode plugin definition - will be replaced
Please use the new Integration Plugin API and the BarcodeMixin
"""
# TODO @matmair remove this with InvenTree 0.7.0
def __init__(self, barcode_data=None):
warnings.warn("using the BarcodePlugin is depreceated", DeprecationWarning)
super().__init__()
self.init(barcode_data)

View File

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
"""
DigiKey barcode decoding
"""
from barcodes.barcode import BarcodePlugin
class DigikeyBarcodePlugin(BarcodePlugin):
PLUGIN_NAME = "DigikeyBarcode"
def validate(self):
"""
TODO: Validation of Digikey barcodes.
"""
return False

View File

@ -2,9 +2,53 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib import admin from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import Build, BuildItem from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from build.models import Build, BuildItem
import part.models
class BuildResource(ModelResource):
"""Class for managing import/export of Build data"""
# For some reason, we need to specify the fields individually for this ModelResource,
# but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case!
pk = Field(attribute='pk')
reference = Field(attribute='reference')
title = Field(attribute='title')
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(part.models.Part))
part_name = Field(attribute='part__full_name', readonly=True)
overdue = Field(attribute='is_overdue', readonly=True, widget=widgets.BooleanWidget())
completed = Field(attribute='completed', readonly=True)
quantity = Field(attribute='quantity')
status = Field(attribute='status')
batch = Field(attribute='batch')
notes = Field(attribute='notes')
class Meta:
models = Build
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'lft', 'rght', 'tree_id', 'level',
]
class BuildAdmin(ImportExportModelAdmin): class BuildAdmin(ImportExportModelAdmin):

View File

@ -12,13 +12,15 @@ from rest_framework import filters, generics
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.helpers import str2bool, isNull from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment import build.admin
import build.serializers import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment
from users.models import Owner from users.models import Owner
@ -71,7 +73,7 @@ class BuildFilter(rest_filters.FilterSet):
return queryset return queryset
class BuildList(generics.ListCreateAPIView): class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of Build objects. """ API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters) - GET: Return list of objects (with filters)
@ -123,6 +125,14 @@ class BuildList(generics.ListCreateAPIView):
return queryset return queryset
def download_queryset(self, queryset, export_format):
dataset = build.admin.BuildResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_BuildOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)

View File

@ -17,15 +17,16 @@ import base64
from secrets import compare_digest from secrets import compare_digest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.apps import apps
from django.db import models, transaction from django.db import models, transaction
from django.db.utils import IntegrityError, OperationalError
from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.utils import IntegrityError, OperationalError from django.contrib.humanize.templatetags.humanize import naturaltime
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.contrib.humanize.templatetags.humanize import naturaltime
from djmoney.settings import CURRENCY_CHOICES from djmoney.settings import CURRENCY_CHOICES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
@ -136,6 +137,19 @@ class BaseInvenTreeSetting(models.Model):
return settings return settings
def get_kwargs(self):
"""
Construct kwargs for doing class-based settings lookup,
depending on *which* class we are.
This is necessary to abtract the settings object
from the implementing class (e.g plugins)
Subclasses should override this function to ensure the kwargs are correctly set.
"""
return {}
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
""" """
@ -319,11 +333,11 @@ class BaseInvenTreeSetting(models.Model):
value = setting.value value = setting.value
# Cast to boolean if necessary # Cast to boolean if necessary
if setting.is_bool(**kwargs): if setting.is_bool():
value = InvenTree.helpers.str2bool(value) value = InvenTree.helpers.str2bool(value)
# Cast to integer if necessary # Cast to integer if necessary
if setting.is_int(**kwargs): if setting.is_int():
try: try:
value = int(value) value = int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
@ -390,19 +404,19 @@ class BaseInvenTreeSetting(models.Model):
@property @property
def name(self): def name(self):
return self.__class__.get_setting_name(self.key) return self.__class__.get_setting_name(self.key, **self.get_kwargs())
@property @property
def default_value(self): def default_value(self):
return self.__class__.get_setting_default(self.key) return self.__class__.get_setting_default(self.key, **self.get_kwargs())
@property @property
def description(self): def description(self):
return self.__class__.get_setting_description(self.key) return self.__class__.get_setting_description(self.key, **self.get_kwargs())
@property @property
def units(self): def units(self):
return self.__class__.get_setting_units(self.key) return self.__class__.get_setting_units(self.key, **self.get_kwargs())
def clean(self, **kwargs): def clean(self, **kwargs):
""" """
@ -512,12 +526,12 @@ class BaseInvenTreeSetting(models.Model):
except self.DoesNotExist: except self.DoesNotExist:
pass pass
def choices(self, **kwargs): def choices(self):
""" """
Return the available choices for this setting (or None if no choices are defined) Return the available choices for this setting (or None if no choices are defined)
""" """
return self.__class__.get_setting_choices(self.key, **kwargs) return self.__class__.get_setting_choices(self.key, **self.get_kwargs())
def valid_options(self): def valid_options(self):
""" """
@ -531,14 +545,14 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices] return [opt[0] for opt in choices]
def is_choice(self, **kwargs): def is_choice(self):
""" """
Check if this setting is a "choice" field Check if this setting is a "choice" field
""" """
return self.__class__.get_setting_choices(self.key, **kwargs) is not None return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None
def as_choice(self, **kwargs): def as_choice(self):
""" """
Render this setting as the "display" value of a choice field, Render this setting as the "display" value of a choice field,
e.g. if the choices are: e.g. if the choices are:
@ -547,7 +561,7 @@ class BaseInvenTreeSetting(models.Model):
then display 'A4 paper' then display 'A4 paper'
""" """
choices = self.get_setting_choices(self.key, **kwargs) choices = self.get_setting_choices(self.key, **self.get_kwargs())
if not choices: if not choices:
return self.value return self.value
@ -558,12 +572,80 @@ class BaseInvenTreeSetting(models.Model):
return self.value return self.value
def is_bool(self, **kwargs): def is_model(self):
"""
Check if this setting references a model instance in the database
"""
return self.model_name() is not None
def model_name(self):
"""
Return the model name associated with this setting
"""
setting = self.get_setting_definition(self.key, **self.get_kwargs())
return setting.get('model', None)
def model_class(self):
"""
Return the model class associated with this setting, if (and only if):
- It has a defined 'model' parameter
- The 'model' parameter is of the form app.model
- The 'model' parameter has matches a known app model
"""
model_name = self.model_name()
if not model_name:
return None
try:
(app, mdl) = model_name.strip().split('.')
except ValueError:
logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'")
return None
app_models = apps.all_models.get(app, None)
if app_models is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'")
return None
model = app_models.get(mdl, None)
if model is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'")
return None
# Looks like we have found a model!
return model
def api_url(self):
"""
Return the API url associated with the linked model,
if provided, and valid!
"""
model_class = self.model_class()
if model_class:
# If a valid class has been found, see if it has registered an API URL
try:
return model_class.get_api_url()
except:
pass
return None
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
""" """
validator = self.__class__.get_setting_validator(self.key, **kwargs) validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_bool(validator) return self.__class__.validator_is_bool(validator)
@ -576,17 +658,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
def setting_type(self, **kwargs): def setting_type(self):
""" """
Return the field type identifier for this setting object Return the field type identifier for this setting object
""" """
if self.is_bool(**kwargs): if self.is_bool():
return 'boolean' return 'boolean'
elif self.is_int(**kwargs): elif self.is_int():
return 'integer' return 'integer'
elif self.is_model():
return 'related field'
else: else:
return 'string' return 'string'
@ -603,12 +688,12 @@ class BaseInvenTreeSetting(models.Model):
return False return False
def is_int(self, **kwargs): def is_int(self,):
""" """
Check if the setting is required to be an integer value: Check if the setting is required to be an integer value:
""" """
validator = self.__class__.get_setting_validator(self.key, **kwargs) validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_int(validator) return self.__class__.validator_is_int(validator)
@ -651,88 +736,7 @@ class BaseInvenTreeSetting(models.Model):
@property @property
def protected(self): def protected(self):
return self.__class__.is_protected(self.key) return self.__class__.is_protected(self.key, **self.get_kwargs())
class GenericReferencedSettingClass:
"""
This mixin can be used to add reference keys to static properties
Sample:
```python
class SampleSetting(GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
class Meta:
unique_together = [
('sample', 'key'),
]
REFERENCE_NAME = 'sample'
@classmethod
def get_setting_definition(cls, key, **kwargs):
# mysampledict contains the dict with all settings for this SettingClass - this could also be a dynamic lookup
kwargs['settings'] = mysampledict
return super().get_setting_definition(key, **kwargs)
sample = models.charKey( # the name for this field is the additonal key and must be set in the Meta class an REFERENCE_NAME
max_length=256,
verbose_name=_('sample')
)
```
"""
REFERENCE_NAME = None
def _get_reference(self):
"""
Returns dict that can be used as an argument for kwargs calls.
Helps to make overriden calls generic for simple reuse.
Usage:
```python
some_random_function(argument0, kwarg1=value1, **self._get_reference())
```
"""
return {
self.REFERENCE_NAME: getattr(self, self.REFERENCE_NAME)
}
"""
We override the following class methods,
so that we can pass the modified key instance as an additional argument
"""
def clean(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
super().clean(**kwargs)
def is_bool(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
return super().is_bool(**kwargs)
@property
def name(self):
return self.__class__.get_setting_name(self.key, **self._get_reference())
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, **self._get_reference())
@property
def description(self):
return self.__class__.get_setting_description(self.key, **self._get_reference())
@property
def units(self):
return self.__class__.get_setting_units(self.key, **self._get_reference())
def choices(self):
return self.__class__.get_setting_choices(self.key, **self._get_reference())
def settings_group_options(): def settings_group_options():
@ -1558,6 +1562,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
return self.__class__.get_setting(self.key, user=self.user) return self.__class__.get_setting(self.key, user=self.user)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
return {
'user': self.user,
}
class PriceBreak(models.Model): class PriceBreak(models.Model):
""" """

View File

@ -28,6 +28,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
choices = serializers.SerializerMethodField() choices = serializers.SerializerMethodField()
model_name = serializers.CharField(read_only=True)
api_url = serializers.CharField(read_only=True)
def get_choices(self, obj): def get_choices(self, obj):
""" """
Returns the choices available for a given item Returns the choices available for a given item
@ -75,6 +79,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
'description', 'description',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
@ -96,6 +102,8 @@ class UserSettingsSerializer(SettingsSerializer):
'user', 'user',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
@ -124,6 +132,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'description', 'description',
'type', 'type',
'choices', 'choices',
'model_name',
'api_url',
] ]
# set Meta class # set Meta class

View File

@ -265,6 +265,13 @@ class LabelConfig(AppConfig):
'width': 70, 'width': 70,
'height': 24, 'height': 24,
}, },
{
'file': 'part_label_code128.html',
'name': 'Barcode Part Label',
'description': 'Simple part label with Code128 barcode',
'width': 70,
'height': 24,
},
] ]
for label in labels: for label in labels:

View File

@ -0,0 +1,33 @@
{% extends "label/label_base.html" %}
{% load barcode %}
{% block style %}
.qr {
position: fixed;
left: 0mm;
top: 0mm;
height: {{ height }}mm;
width: {{ height }}mm;
}
.part {
font-family: Arial, Helvetica, sans-serif;
display: inline;
position: absolute;
left: {{ height }}mm;
top: 2mm;
}
{% endblock %}
{% block content %}
<img class='qr' src='{% barcode qr_data %}'>
<div class='part'>
{{ part.full_name }}
</div>
{% endblock %}

View File

@ -5,20 +5,28 @@ from __future__ import unicode_literals
import os import os
from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from InvenTree.helpers import validateFilterString from InvenTree.helpers import validateFilterString
from InvenTree.api_tester import InvenTreeAPITestCase
from .models import StockItemLabel, StockLocationLabel from .models import StockItemLabel, StockLocationLabel
from stock.models import StockItem from stock.models import StockItem
class LabelTest(TestCase): class LabelTest(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'location',
'stock'
]
def setUp(self) -> None: def setUp(self) -> None:
super().setUp()
# ensure the labels were created # ensure the labels were created
apps.get_app_config('label').create_labels() apps.get_app_config('label').create_labels()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,9 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine
from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine
@ -92,6 +93,23 @@ class SalesOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ('customer',) autocomplete_fields = ('customer',)
class PurchaseOrderResource(ModelResource):
"""
Class for managing import / export of PurchaseOrder data
"""
# Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
# Is this order overdue?
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta:
model = PurchaseOrder
skip_unchanged = True
clean_model_instances = True
class PurchaseOrderLineItemResource(ModelResource): class PurchaseOrderLineItemResource(ModelResource):
""" Class for managing import / export of PurchaseOrderLineItem data """ """ Class for managing import / export of PurchaseOrderLineItem data """
@ -117,6 +135,23 @@ class PurchaseOrderExtraLineResource(ModelResource):
model = PurchaseOrderExtraLine model = PurchaseOrderExtraLine
class SalesOrderResource(ModelResource):
"""
Class for managing import / export of SalesOrder data
"""
# Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
# Is this order overdue?
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta:
model = SalesOrder
skip_unchanged = True
clean_model_instances = True
class SalesOrderLineItemResource(ModelResource): class SalesOrderLineItemResource(ModelResource):
""" """
Class for managing import / export of SalesOrderLineItem data Class for managing import / export of SalesOrderLineItem data

View File

@ -17,10 +17,11 @@ from company.models import SupplierPart
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool, DownloadFile from InvenTree.helpers import str2bool, DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from order.admin import PurchaseOrderLineItemResource from order.admin import PurchaseOrderResource, PurchaseOrderLineItemResource
from order.admin import SalesOrderResource
import order.models as models import order.models as models
import order.serializers as serializers import order.serializers as serializers
from part.models import Part from part.models import Part
@ -110,7 +111,7 @@ class PurchaseOrderFilter(rest_filters.FilterSet):
] ]
class PurchaseOrderList(generics.ListCreateAPIView): class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of PurchaseOrder objects """ API endpoint for accessing a list of PurchaseOrder objects
- GET: Return list of PurchaseOrder objects (with filters) - GET: Return list of PurchaseOrder objects (with filters)
@ -160,6 +161,15 @@ class PurchaseOrderList(generics.ListCreateAPIView):
return queryset return queryset
def download_queryset(self, queryset, export_format):
dataset = PurchaseOrderResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
# Perform basic filtering # Perform basic filtering
@ -407,7 +417,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
return queryset return queryset
class PurchaseOrderLineItemList(generics.ListCreateAPIView): class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of PurchaseOrderLineItem objects """ API endpoint for accessing a list of PurchaseOrderLineItem objects
- GET: Return a list of PurchaseOrder Line Item objects - GET: Return a list of PurchaseOrder Line Item objects
@ -460,25 +470,19 @@ class PurchaseOrderLineItemList(generics.ListCreateAPIView):
return queryset return queryset
def download_queryset(self, queryset, export_format):
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrderItems.{export_format}"
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrderData.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:
@ -580,7 +584,7 @@ class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attachme
serializer_class = serializers.SalesOrderAttachmentSerializer serializer_class = serializers.SalesOrderAttachmentSerializer
class SalesOrderList(generics.ListCreateAPIView): class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
""" """
API endpoint for accessing a list of SalesOrder objects. API endpoint for accessing a list of SalesOrder objects.
@ -630,6 +634,15 @@ class SalesOrderList(generics.ListCreateAPIView):
return queryset return queryset
def download_queryset(self, queryset, export_format):
dataset = SalesOrderResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_SalesOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
""" """
Perform custom filtering operations on the SalesOrder queryset. Perform custom filtering operations on the SalesOrder queryset.

View File

@ -4,8 +4,8 @@ from __future__ import unicode_literals
from django.contrib import admin from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets import import_export.widgets as widgets
from company.models import SupplierPart from company.models import SupplierPart

View File

@ -49,7 +49,7 @@ from . import serializers as part_serializers
from InvenTree.helpers import str2bool, isNull, increment from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -847,7 +847,7 @@ class PartFilter(rest_filters.FilterSet):
virtual = rest_filters.BooleanFilter() virtual = rest_filters.BooleanFilter()
class PartList(generics.ListCreateAPIView): class PartList(APIDownloadMixin, generics.ListCreateAPIView):
""" """
API endpoint for accessing a list of Part objects API endpoint for accessing a list of Part objects
@ -897,6 +897,14 @@ class PartList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
def download_queryset(self, queryset, export_format):
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" """
Overide the 'list' method, as the PartCategory objects are Overide the 'list' method, as the PartCategory objects are
@ -908,22 +916,6 @@ class PartList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:

View File

@ -49,6 +49,8 @@ from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -2231,7 +2233,7 @@ class Part(MPTTModel):
for child in children: for child in children:
parts.append(child) parts.append(child)
# Immediate parent # Immediate parent, and siblings
if self.variant_of: if self.variant_of:
parts.append(self.variant_of) parts.append(self.variant_of)
@ -2292,7 +2294,7 @@ def after_save_part(sender, instance: Part, created, **kwargs):
Function to be executed after a Part is saved Function to be executed after a Part is saved
""" """
if not created: if not created and not InvenTree.ready.isImportingData():
# Check part stock only if we are *updating* the part (not creating it) # Check part stock only if we are *updating* the part (not creating it)
# Run this check in the background # Run this check in the background

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.urls import reverse, path, re_path
from django.urls import reverse
from django.urls import path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -12,8 +10,8 @@ from rest_framework.views import APIView
from stock.models import StockItem from stock.models import StockItem
from stock.serializers import StockItemSerializer from stock.serializers import StockItemSerializer
from barcodes.plugins.inventree_barcode import InvenTreeBarcodePlugin from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
from barcodes.barcode import hash_barcode from plugin.builtin.barcodes.mixins import hash_barcode
from plugin import registry from plugin import registry

View File

@ -13,7 +13,8 @@ references model objects actually exist in the database.
import json import json
from barcodes.barcode import BarcodePlugin from plugin import IntegrationPluginBase
from plugin.mixins import BarcodeMixin
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part
@ -21,7 +22,7 @@ from part.models import Part
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
class InvenTreeBarcodePlugin(BarcodePlugin): class InvenTreeBarcodePlugin(BarcodeMixin, IntegrationPluginBase):
PLUGIN_NAME = "InvenTreeBarcode" PLUGIN_NAME = "InvenTreeBarcode"
@ -83,7 +84,7 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
item = StockItem.objects.get(pk=pk) item = StockItem.objects.get(pk=pk)
return item return item
except (ValueError, StockItem.DoesNotExist): # pragma: no cover except (ValueError, StockItem.DoesNotExist): # pragma: no cover
raise ValidationError({k, "Stock item does not exist"}) raise ValidationError({k: "Stock item does not exist"})
return None return None
@ -111,7 +112,7 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
loc = StockLocation.objects.get(pk=pk) loc = StockLocation.objects.get(pk=pk)
return loc return loc
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
raise ValidationError({k, "Stock location does not exist"}) raise ValidationError({k: "Stock location does not exist"})
return None return None
@ -132,12 +133,12 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
try: try:
pk = self.data[k]['id'] pk = self.data[k]['id']
except (AttributeError, KeyError): except (AttributeError, KeyError):
raise ValidationError({k, 'id parameter not supplied'}) raise ValidationError({k: 'id parameter not supplied'})
try: try:
part = Part.objects.get(pk=pk) part = Part.objects.get(pk=pk)
return part return part
except (ValueError, Part.DoesNotExist): # pragma: no cover except (ValueError, Part.DoesNotExist): # pragma: no cover
raise ValidationError({k, 'Part does not exist'}) raise ValidationError({k: 'Part does not exist'})
return None return None

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Unit tests for InvenTreeBarcodePlugin"""
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status
class TestInvenTreeBarcode(APITestCase):
fixtures = [
'category',
'part',
'location',
'stock'
]
def setUp(self):
# Create a user for auth
user = get_user_model()
user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def test_errors(self):
"""
Test all possible error cases for assigment action
"""
def test_assert_error(barcode_data):
response = self.client.post(
reverse('api-barcode-link'), format='json',
data={
'barcode': barcode_data,
'stockitem': 521
}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('error', response.data)
# test with already existing stock
test_assert_error('{"stockitem": 521}')
# test with already existing stock location
test_assert_error('{"stocklocation": 7}')
# test with already existing part location
test_assert_error('{"part": 10004}')
# test with hash
test_assert_error('{"blbla": 10004}')
def test_scan(self):
"""
Test that a barcode can be scanned
"""
response = self.client.post(reverse('api-barcode-scan'), format='json', data={'barcode': 'blbla=10004'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('success', response.data)

View File

@ -17,7 +17,7 @@ from django.dispatch.dispatcher import receiver
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
import common.notifications import common.notifications
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase, isImportingData
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from plugin.registry import registry from plugin.registry import registry
@ -113,6 +113,10 @@ def allow_table_event(table_name):
We *do not* want events to be fired for some tables! We *do not* want events to be fired for some tables!
""" """
if isImportingData():
# Prevent table events during the data import process
return False
table_name = table_name.lower().strip() table_name = table_name.lower().strip()
# Ignore any tables which start with these prefixes # Ignore any tables which start with these prefixes

View File

@ -18,7 +18,7 @@ from ..builtin.integration.mixins import (
from common.notifications import SingleNotificationMethod, BulkNotificationMethod from common.notifications import SingleNotificationMethod, BulkNotificationMethod
from ..builtin.action.mixins import ActionMixin from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin from ..builtin.barcodes.mixins import BarcodeMixin
__all__ = [ __all__ = [
'ActionMixin', 'ActionMixin',

View File

@ -102,7 +102,7 @@ class PluginConfig(models.Model):
return ret return ret
class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): class PluginSetting(common.models.BaseInvenTreeSetting):
""" """
This model represents settings for individual plugins This model represents settings for individual plugins
""" """
@ -112,7 +112,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
('plugin', 'key'), ('plugin', 'key'),
] ]
REFERENCE_NAME = 'plugin' plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
@ -142,16 +148,18 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
return super().get_setting_definition(key, **kwargs) return super().get_setting_definition(key, **kwargs)
plugin = models.ForeignKey( def get_kwargs(self):
PluginConfig, """
related_name='settings', Explicit kwargs required to uniquely identify a particular setting object,
null=False, in addition to the 'key' parameter
verbose_name=_('Plugin'), """
on_delete=models.CASCADE,
) return {
'plugin': self.plugin,
}
class NotificationUserSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): class NotificationUserSetting(common.models.BaseInvenTreeSetting):
""" """
This model represents notification settings for a user This model represents notification settings for a user
""" """
@ -161,8 +169,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
('method', 'user', 'key'), ('method', 'user', 'key'),
] ]
REFERENCE_NAME = 'method'
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
from common.notifications import storage from common.notifications import storage
@ -171,6 +177,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
return super().get_setting_definition(key, **kwargs) return super().get_setting_definition(key, **kwargs)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
return {
'method': self.method,
'user': self.user,
}
method = models.CharField( method = models.CharField(
max_length=255, max_length=255,
verbose_name=_('Method'), verbose_name=_('Method'),

View File

@ -65,6 +65,16 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
], ],
'default': 'A', 'default': 'A',
}, },
'SELECT_COMPANY': {
'name': 'Company',
'description': 'Select a company object from the database',
'model': 'company.company',
},
'SELECT_PART': {
'name': 'Part',
'description': 'Select a part object from the database',
'model': 'part.part',
},
} }
NAVIGATION = [ NAVIGATION = [

View File

@ -6,6 +6,6 @@
<em>This location has no sublocations!</em> <em>This location has no sublocations!</em>
<ul> <ul>
<li><b>Location Name</b>: {{ location.name }}</li> <li><strong>Location Name</strong>: {{ location.name }}</li>
<li><b>Location Path</b>: {{ location.pathstring }}</li> <li><strong>Location Path</strong>: {{ location.pathstring }}</li>
</ul> </ul>

View File

@ -86,6 +86,22 @@ class BarcodeAPITest(APITestCase):
self.assertIn('barcode_data', response.data) self.assertIn('barcode_data', response.data)
self.assertEqual(response.data['part']['pk'], 1) self.assertEqual(response.data['part']['pk'], 1)
def test_invalid_part(self):
"""Test response for invalid part"""
response = self.client.post(
self.scan_url,
{
'barcode': {
'part': 999999999,
}
},
format='json'
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['part'], 'Part does not exist')
def test_find_stock_item(self): def test_find_stock_item(self):
""" """
Test that we can lookup a stock item based on ID Test that we can lookup a stock item based on ID
@ -106,6 +122,23 @@ class BarcodeAPITest(APITestCase):
self.assertIn('barcode_data', response.data) self.assertIn('barcode_data', response.data)
self.assertEqual(response.data['stockitem']['pk'], 1) self.assertEqual(response.data['stockitem']['pk'], 1)
def test_invalid_item(self):
"""Test response for invalid stock item"""
response = self.client.post(
self.scan_url,
{
'barcode': {
'stockitem': 999999999,
}
},
format='json'
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['stockitem'], 'Stock item does not exist')
def test_find_location(self): def test_find_location(self):
""" """
Test that we can lookup a stock location based on ID Test that we can lookup a stock location based on ID
@ -126,6 +159,23 @@ class BarcodeAPITest(APITestCase):
self.assertIn('barcode_data', response.data) self.assertIn('barcode_data', response.data)
self.assertEqual(response.data['stocklocation']['pk'], 1) self.assertEqual(response.data['stocklocation']['pk'], 1)
def test_invalid_location(self):
"""Test response for an invalid location"""
response = self.client.post(
self.scan_url,
{
'barcode': {
'stocklocation': 999999999,
}
},
format='json'
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['stocklocation'], 'Stock location does not exist')
def test_integer_barcode(self): def test_integer_barcode(self):
response = self.postBarcode(self.scan_url, '123456789') response = self.postBarcode(self.scan_url, '123456789')
@ -214,57 +264,3 @@ class BarcodeAPITest(APITestCase):
self.assertIn('error', data) self.assertIn('error', data)
self.assertNotIn('success', data) self.assertNotIn('success', data)
class TestInvenTreeBarcode(APITestCase):
fixtures = [
'category',
'part',
'location',
'stock'
]
def setUp(self):
# Create a user for auth
user = get_user_model()
user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def test_errors(self):
"""
Test all possible error cases for assigment action
"""
def test_assert_error(barcode_data):
response = self.client.post(
reverse('api-barcode-link'), format='json',
data={
'barcode': barcode_data,
'stockitem': 521
}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('error', response.data)
# test with already existing stock
test_assert_error('{"stockitem": 521}')
# test with already existing stock location
test_assert_error('{"stocklocation": 7}')
# test with already existing part location
test_assert_error('{"part": 10004}')
# test with hash
test_assert_error('{"blbla": 10004}')
def test_scan(self):
"""
Test that a barcode can be scanned
"""
response = self.client.post(reverse('api-barcode-scan'), format='json', data={'barcode': 'blbla=10004'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('success', response.data)

View File

@ -33,7 +33,7 @@ from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
from InvenTree.helpers import DownloadFile from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from order.models import PurchaseOrder from order.models import PurchaseOrder
@ -505,7 +505,7 @@ class StockFilter(rest_filters.FilterSet):
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte') updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
class StockList(generics.ListCreateAPIView): class StockList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects """ API endpoint for list view of Stock objects
- GET: Return a list of all StockItem objects (with optional query filters) - GET: Return a list of all StockItem objects (with optional query filters)
@ -646,6 +646,22 @@ class StockList(generics.ListCreateAPIView):
return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data)) return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data))
def download_queryset(self, queryset, export_format):
"""
Download this queryset as a file.
Uses the APIDownloadMixin mixin class
"""
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_StockItems_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" """
Override the 'list' method, as the StockLocation objects Override the 'list' method, as the StockLocation objects
@ -658,25 +674,6 @@ class StockList(generics.ListCreateAPIView):
params = request.query_params params = request.query_params
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:

View File

@ -30,7 +30,8 @@ from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from InvenTree import helpers import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import common.models import common.models
@ -137,7 +138,7 @@ class StockLocation(InvenTreeTree):
def format_barcode(self, **kwargs): def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this StockLocation object """ """ Return a JSON string for formatting a barcode for this StockLocation object """
return helpers.MakeBarcode( return InvenTree.helpers.MakeBarcode(
'stocklocation', 'stocklocation',
self.pk, self.pk,
{ {
@ -555,7 +556,14 @@ class StockItem(MPTTModel):
# If the item points to a build, check that the Part references match # If the item points to a build, check that the Part references match
if self.build: if self.build:
if not self.part == self.build.part:
if self.part == self.build.part:
# Part references match exactly
pass
elif self.part in self.build.part.get_conversion_options():
# Part reference is one of the valid conversion options for the build output
pass
else:
raise ValidationError({ raise ValidationError({
'build': _("Build reference does not point to the same part object") 'build': _("Build reference does not point to the same part object")
}) })
@ -577,7 +585,7 @@ class StockItem(MPTTModel):
Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change) Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
""" """
return helpers.MakeBarcode( return InvenTree.helpers.MakeBarcode(
"stockitem", "stockitem",
self.id, self.id,
{ {
@ -1775,7 +1783,7 @@ class StockItem(MPTTModel):
sn=self.serial) sn=self.serial)
else: else:
s = '{n} x {part}'.format( s = '{n} x {part}'.format(
n=helpers.decimal2string(self.quantity), n=InvenTree.helpers.decimal2string(self.quantity),
part=self.part.full_name) part=self.part.full_name)
if self.location: if self.location:
@ -1783,7 +1791,7 @@ class StockItem(MPTTModel):
if self.purchase_order: if self.purchase_order:
s += " ({pre}{po})".format( s += " ({pre}{po})".format(
pre=helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"), pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"),
po=self.purchase_order, po=self.purchase_order,
) )
@ -1851,7 +1859,7 @@ class StockItem(MPTTModel):
result_map = {} result_map = {}
for result in results: for result in results:
key = helpers.generateTestKey(result.test) key = InvenTree.helpers.generateTestKey(result.test)
result_map[key] = result result_map[key] = result
# Do we wish to "cascade" and include test results from installed stock items? # Do we wish to "cascade" and include test results from installed stock items?
@ -1898,7 +1906,7 @@ class StockItem(MPTTModel):
failed = 0 failed = 0
for test in required: for test in required:
key = helpers.generateTestKey(test.test_name) key = InvenTree.helpers.generateTestKey(test.test_name)
if key in results: if key in results:
result = results[key] result = results[key]
@ -1949,7 +1957,7 @@ class StockItem(MPTTModel):
# Attempt to validate report filter (skip if invalid) # Attempt to validate report filter (skip if invalid)
try: try:
filters = helpers.validateFilterString(test_report.filters) filters = InvenTree.helpers.validateFilterString(test_report.filters)
if item_query.filter(**filters).exists(): if item_query.filter(**filters).exists():
reports.append(test_report) reports.append(test_report)
except (ValidationError, FieldError): except (ValidationError, FieldError):
@ -1977,7 +1985,7 @@ class StockItem(MPTTModel):
for lbl in label.models.StockItemLabel.objects.filter(enabled=True): for lbl in label.models.StockItemLabel.objects.filter(enabled=True):
try: try:
filters = helpers.validateFilterString(lbl.filters) filters = InvenTree.helpers.validateFilterString(lbl.filters)
if item_query.filter(**filters).exists(): if item_query.filter(**filters).exists():
labels.append(lbl) labels.append(lbl)
@ -2016,8 +2024,9 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
Function to be executed after a StockItem object is deleted Function to be executed after a StockItem object is deleted
""" """
# Run this check in the background if not InvenTree.ready.isImportingData():
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) # Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
@ -2026,8 +2035,9 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
Hook function to be executed after StockItem object is saved/updated Hook function to be executed after StockItem object is saved/updated
""" """
# Run this check in the background if not InvenTree.ready.isImportingData():
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) # Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTreeAttachment):
@ -2170,7 +2180,7 @@ class StockItemTestResult(models.Model):
@property @property
def key(self): def key(self):
return helpers.generateTestKey(self.test) return InvenTree.helpers.generateTestKey(self.test)
stock_item = models.ForeignKey( stock_item = models.ForeignKey(
StockItem, StockItem,

View File

@ -71,9 +71,24 @@ function editSetting(key, options={}) {
help_text: response.description, help_text: response.description,
type: response.type, type: response.type,
choices: response.choices, choices: response.choices,
value: response.value,
} }
}; };
// Foreign key lookup available!
if (response.type == 'related field') {
if (response.model_name && response.api_url) {
fields.value.type = 'related field';
fields.value.model = response.model_name.split('.').at(-1);
fields.value.api_url = response.api_url;
} else {
// Unknown / unsupported model type, default to 'text' field
fields.value.type = 'text';
console.warn(`Unsupported model type: '${response.model_name}' for setting '${response.key}'`);
}
}
constructChangeForm(fields, { constructChangeForm(fields, {
url: url, url: url,
method: 'PATCH', method: 'PATCH',

View File

@ -1056,6 +1056,7 @@ function loadBuildOutputTable(build_info, options={}) {
'{% url "api-stock-test-result-list" %}', '{% url "api-stock-test-result-list" %}',
{ {
build: build_info.pk, build: build_info.pk,
ordering: '-date',
}, },
{ {
success: function(results) { success: function(results) {
@ -2354,7 +2355,7 @@ function loadBuildTable(table, options) {
var filterTarget = options.filterTarget || null; var filterTarget = options.filterTarget || null;
setupFilterList('build', table, filterTarget); setupFilterList('build', table, filterTarget, {download: true});
$(table).inventreeTable({ $(table).inventreeTable({
method: 'get', method: 'get',

View File

@ -1394,7 +1394,9 @@ function loadPurchaseOrderTable(table, options) {
filters[key] = options.params[key]; filters[key] = options.params[key];
} }
setupFilterList('purchaseorder', $(table)); var target = '#filter-list-purchaseorder';
setupFilterList('purchaseorder', $(table), target, {download: true});
$(table).inventreeTable({ $(table).inventreeTable({
url: '{% url "api-po-list" %}', url: '{% url "api-po-list" %}',
@ -2091,7 +2093,9 @@ function loadSalesOrderTable(table, options) {
options.url = options.url || '{% url "api-so-list" %}'; options.url = options.url || '{% url "api-so-list" %}';
setupFilterList('salesorder', $(table)); var target = '#filter-list-salesorder';
setupFilterList('salesorder', $(table), target, {download: true});
$(table).inventreeTable({ $(table).inventreeTable({
url: options.url, url: options.url,

View File

@ -648,36 +648,6 @@ class Owner(models.Model):
owner_type=content_type_id) owner_type=content_type_id)
except Owner.DoesNotExist: except Owner.DoesNotExist:
pass pass
else:
# Check whether user_or_group is a Group instance
try:
group = Group.objects.get(pk=user_or_group.id)
except Group.DoesNotExist:
group = None
if group:
try:
owner = Owner.objects.get(owner_id=user_or_group.id,
owner_type=content_type_id_list[0])
except Owner.DoesNotExist:
pass
return owner
# Check whether user_or_group is a User instance
try:
user = user_model.objects.get(pk=user_or_group.id)
except user_model.DoesNotExist:
user = None
if user:
try:
owner = Owner.objects.get(owner_id=user_or_group.id,
owner_type=content_type_id_list[1])
except Owner.DoesNotExist:
pass
return owner
return owner return owner

View File

@ -95,6 +95,9 @@ RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} ${INVENTREE_HOME} RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} ${INVENTREE_HOME}
# Ref: https://github.blog/2022-04-12-git-security-vulnerability-announced/
RUN git config --global --add safe.directory ${INVENTREE_HOME}
# Checkout against a particular git tag # Checkout against a particular git tag
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi