Merge branch 'master' of https://github.com/inventree/InvenTree into not-working-tests

This commit is contained in:
Matthias Mair 2022-05-16 19:01:40 +02:00
commit 8340daf77b
122 changed files with 13382 additions and 12702 deletions

View File

@ -2,9 +2,6 @@
Main JSON interface views Main JSON interface views
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse

View File

@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
return response return response
def put(self, url, data, expected_code=None, format='json'):
"""
Issue a PUT request
"""
response = self.client.put(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def options(self, url, expected_code=None): def options(self, url, expected_code=None):
""" """
Issue an OPTIONS request Issue an OPTIONS request

View File

@ -4,11 +4,16 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 48 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
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 'mixin' support
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977 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 PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint - Adds "export to file" functionality for SalesOrder API endpoint

View File

@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
update = True update = True
# Backend currency has changed? # Backend currency has changed?
if not base_currency == backend.base_currency: if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True update = True

View File

@ -1,7 +1,5 @@
""" Custom fields used in InvenTree """ """ Custom fields used in InvenTree """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys import sys
from .validators import allowable_url_schemes from .validators import allowable_url_schemes

View File

@ -2,8 +2,6 @@
Helper forms which subclass Django forms to provide additional functionality Helper forms which subclass Django forms to provide additional functionality
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from urllib.parse import urlencode from urllib.parse import urlencode
import logging import logging

View File

@ -224,7 +224,7 @@ def increment(n):
groups = result.groups() groups = result.groups()
# If we cannot match the regex, then simply return the provided value # If we cannot match the regex, then simply return the provided value
if not len(groups) == 2: if len(groups) != 2:
return value return value
prefix, number = groups prefix, number = groups
@ -536,7 +536,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
raise ValidationError([_("No serial numbers found")]) raise ValidationError([_("No serial numbers found")])
# The number of extracted serial numbers must match the expected quantity # The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers): if expected_quantity != len(numbers):
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
return numbers return numbers
@ -575,7 +575,7 @@ def validateFilterString(value, model=None):
pair = group.split('=') pair = group.split('=')
if not len(pair) == 2: if len(pair) != 2:
raise ValidationError( raise ValidationError(
"Invalid group: {g}".format(g=group) "Invalid group: {g}".format(g=group)
) )

View File

@ -250,7 +250,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info = super().get_field_info(field) field_info = super().get_field_info(field)
# If a default value is specified for the serializer field, add it! # If a default value is specified for the serializer field, add it!
if 'default' not in field_info and not field.default == empty: if 'default' not in field_info and field.default != empty:
field_info['default'] = field.get_default() field_info['default'] = field.get_default()
# Force non-nullable fields to read as "required" # Force non-nullable fields to read as "required"

View File

@ -259,7 +259,7 @@ class InvenTreeAttachment(models.Model):
new_file = os.path.abspath(new_file) new_file = os.path.abspath(new_file)
# Check that there are no directory tricks going on... # Check that there are no directory tricks going on...
if not os.path.dirname(new_file) == attachment_dir: if os.path.dirname(new_file) != attachment_dir:
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
raise ValidationError(_("Invalid attachment directory")) raise ValidationError(_("Invalid attachment directory"))

View File

@ -2,9 +2,6 @@
Serializers used in various InvenTree apps Serializers used in various InvenTree apps
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import tablib import tablib

View File

@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', []) EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
logger.warning("extra_url_schemes not correctly formatted") logger.warning("extra_url_schemes not correctly formatted")
EXTRA_URL_SCHEMES = [] EXTRA_URL_SCHEMES = []

View File

@ -126,8 +126,8 @@ def heartbeat():
try: try:
from django_q.models import Success from django_q.models import Success
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: # pragma: no cover except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform heartbeat task - App registry not ready")
return return
threshold = timezone.now() - timedelta(minutes=30) threshold = timezone.now() - timedelta(minutes=30)
@ -204,7 +204,7 @@ def check_for_updates():
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
if not response.status_code == 200: if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
data = json.loads(response.text) data = json.loads(response.text)
@ -216,13 +216,13 @@ def check_for_updates():
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag) match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
if not len(match.groups()) == 3: if len(match.groups()) != 3:
logger.warning(f"Version '{tag}' did not match expected pattern") logger.warning(f"Version '{tag}' did not match expected pattern")
return return
latest_version = [int(x) for x in match.groups()] latest_version = [int(x) for x in match.groups()]
if not len(latest_version) == 3: if len(latest_version) != 3:
raise ValueError(f"Version '{tag}' is not correct format") raise ValueError(f"Version '{tag}' is not correct format")
logger.info(f"Latest InvenTree version: '{tag}'") logger.info(f"Latest InvenTree version: '{tag}'")

View File

@ -5,8 +5,6 @@ In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap). as JSON objects and passing them to modal forms (using jQuery / bootstrap).
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import json import json
@ -627,7 +625,7 @@ class SetPasswordView(AjaxUpdateView):
if valid: if valid:
# Passwords must match # Passwords must match
if not p1 == p2: if p1 != p2:
error = _('Password fields must match') error = _('Password fields must match')
form.add_error('enter_password', error) form.add_error('enter_password', error)
form.add_error('confirm_password', error) form.add_error('confirm_password', error)

View File

@ -2,9 +2,6 @@
JSON API for the Build app JSON API for the Build app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.urls import include, re_path from django.urls import include, re_path
from rest_framework import filters, generics from rest_framework import filters, generics

View File

@ -2,8 +2,6 @@
Build database model definitions Build database model definitions
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal import decimal
import os import os
@ -777,7 +775,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if not output.is_building: if not output.is_building:
raise ValidationError(_("Build output is already completed")) raise ValidationError(_("Build output is already completed"))
if not output.build == self: if output.build != self:
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
@ -1240,7 +1238,7 @@ class BuildItem(models.Model):
}) })
# Quantity must be 1 for serialized stock # Quantity must be 1 for serialized stock
if self.stock_item.serialized and not self.quantity == 1: if self.stock_item.serialized and self.quantity != 1:
raise ValidationError({ raise ValidationError({
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })

View File

@ -2,9 +2,6 @@
JSON serializers for Build API JSON serializers for Build API
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import transaction from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -2,9 +2,6 @@
Django views for interacting with Build objects Django views for interacting with Build objects
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView

View File

@ -2,9 +2,6 @@
Provides a JSON API for common components. Provides a JSON API for common components.
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json import json
from django.http.response import HttpResponse from django.http.response import HttpResponse

View File

@ -199,7 +199,7 @@ class FileManager:
try: try:
# Excel import casts number-looking-items into floats, which is annoying # Excel import casts number-looking-items into floats, which is annoying
if item == int(item) and not str(item) == str(int(item)): if item == int(item) and str(item) != str(int(item)):
data[idx] = int(item) data[idx] = int(item)
except ValueError: except ValueError:
pass pass

View File

@ -2,9 +2,6 @@
Django forms for interacting with common objects Django forms for interacting with common objects
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _

View File

@ -3,9 +3,6 @@ Common database model definitions.
These models are 'generic' and do not fit a particular business logic object. These models are 'generic' and do not fit a particular business logic object.
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import decimal import decimal
import math import math
@ -1802,10 +1799,8 @@ class WebhookEndpoint(models.Model):
def process_webhook(self): def process_webhook(self):
if self.token: if self.token:
self.verify = VerificationMethod.TOKEN self.verify = VerificationMethod.TOKEN
# TODO make a object-setting
if self.secret: if self.secret:
self.verify = VerificationMethod.HMAC self.verify = VerificationMethod.HMAC
# TODO make a object-setting
return True return True
def validate_token(self, payload, headers, request): def validate_token(self, payload, headers, request):

View File

@ -2,9 +2,6 @@
JSON serializers for common components JSON serializers for common components
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.helpers import get_objectreference from InvenTree.helpers import get_objectreference

View File

@ -2,9 +2,6 @@
User-configurable settings for the common app User-configurable settings for the common app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from moneyed import CURRENCIES from moneyed import CURRENCIES
from django.conf import settings from django.conf import settings

View File

@ -1,6 +1,3 @@
""" """
Unit tests for the views associated with the 'common' app Unit tests for the views associated with the 'common' app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -133,7 +133,7 @@ class SettingsTest(TestCase):
if description is None: if description is None:
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
if not key == key.upper(): if key != key.upper():
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
def test_defaults(self): def test_defaults(self):

View File

@ -2,9 +2,6 @@
Django views for interacting with common models Django views for interacting with common models
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -2,9 +2,6 @@
Provides a JSON API for the Company app Provides a JSON API for the Company app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
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

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Company app Django Forms for interacting with Company app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField

View File

@ -2,9 +2,6 @@
Company database model definitions Company database model definitions
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -494,7 +491,7 @@ class SupplierPart(models.Model):
# Ensure that the linked manufacturer_part points to the same part! # Ensure that the linked manufacturer_part points to the same part!
if self.manufacturer_part and self.part: if self.manufacturer_part and self.part:
if not self.manufacturer_part.part == self.part: if self.manufacturer_part.part != self.part:
raise ValidationError({ raise ValidationError({
'manufacturer_part': _("Linked manufacturer part must reference the same base part"), 'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
}) })

View File

@ -1,8 +1,5 @@
""" Unit tests for Company views (see views.py) """ """ Unit tests for Company views (see views.py) """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model

View File

@ -2,10 +2,6 @@
Django views for interacting with Company app Django views for interacting with Company app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
@ -162,7 +158,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
self.response = response self.response = response
# Check for valid response code # Check for valid response code
if not response.status_code == 200: if response.status_code != 200:
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code)) form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return return

View File

@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
if os.path.exists(dst_file): if os.path.exists(dst_file):
# File already exists - let's see if it is the "same" # File already exists - let's see if it is the "same"
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True

View File

@ -2,9 +2,6 @@
Label printing models Label printing models
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys import sys
import os import os
import logging import logging

View File

@ -1,8 +1,5 @@
# Tests for labels # Tests for labels
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.urls import reverse from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase

View File

@ -1,8 +1,5 @@
# Tests for labels # Tests for labels
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
from django.conf import settings from django.conf import settings

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

@ -2,9 +2,6 @@
JSON API for the Order app JSON API for the Order app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.db.models import Q, F from django.db.models import Q, F
@ -27,6 +24,8 @@ import order.serializers as serializers
from part.models import Part from part.models import Part
from users.models import Owner from users.models import Owner
from plugin.serializers import MetadataSerializer
class GeneralExtraLineList: class GeneralExtraLineList:
""" """
@ -347,6 +346,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderIssueSerializer serializer_class = serializers.PurchaseOrderIssueSerializer
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating PurchaseOrder metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
queryset = models.PurchaseOrder.objects.all()
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView): class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
""" """
API endpoint to receive stock items against a purchase order. API endpoint to receive stock items against a purchase order.
@ -916,6 +924,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.SalesOrderCompleteSerializer serializer_class = serializers.SalesOrderCompleteSerializer
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating SalesOrder metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
queryset = models.SalesOrder.objects.all()
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView): class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
""" """
API endpoint to allocation stock items against a SalesOrder, API endpoint to allocation stock items against a SalesOrder,
@ -1138,10 +1155,13 @@ order_api_urls = [
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'), re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'), re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
# PurchaseOrder detail API endpoint
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'), re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])), ])),
@ -1178,10 +1198,13 @@ order_api_urls = [
# Sales order detail view # Sales order detail view
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
# SalesOrder detail endpoint
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'), re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
])), ])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Order objects Django Forms for interacting with Order objects
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0066_alter_purchaseorder_supplier'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='salesorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -30,7 +30,9 @@ from users import models as UserModels
from part import models as PartModels from part import models as PartModels
from stock import models as stock_models from stock import models as stock_models
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from plugin.events import trigger_event from plugin.events import trigger_event
from plugin.models import MetadataMixin
import InvenTree.helpers import InvenTree.helpers
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
@ -97,7 +99,7 @@ def get_next_so_number():
return reference return reference
class Order(ReferenceIndexingMixin): class Order(MetadataMixin, ReferenceIndexingMixin):
""" Abstract model for an order. """ Abstract model for an order.
Instances of this class: Instances of this class:
@ -306,7 +308,7 @@ class PurchaseOrder(Order):
except ValueError: except ValueError:
raise ValidationError({'quantity': _("Invalid quantity provided")}) raise ValidationError({'quantity': _("Invalid quantity provided")})
if not supplier_part.supplier == self.supplier: if supplier_part.supplier != self.supplier:
raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
if group: if group:
@ -445,7 +447,7 @@ class PurchaseOrder(Order):
if barcode is None: if barcode is None:
barcode = '' barcode = ''
if not self.status == PurchaseOrderStatus.PLACED: if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError( raise ValidationError(
"Lines can only be received against an order marked as 'PLACED'" "Lines can only be received against an order marked as 'PLACED'"
) )
@ -729,7 +731,7 @@ class SalesOrder(Order):
Return True if this order can be cancelled Return True if this order can be cancelled
""" """
if not self.status == SalesOrderStatus.PENDING: if self.status != SalesOrderStatus.PENDING:
return False return False
return True return True
@ -1295,7 +1297,7 @@ class SalesOrderAllocation(models.Model):
raise ValidationError({'item': _('Stock item has not been assigned')}) raise ValidationError({'item': _('Stock item has not been assigned')})
try: try:
if not self.line.part == self.item.part: if self.line.part != self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part') errors['item'] = _('Cannot allocate stock item to a line with a different part')
except PartModels.Part.DoesNotExist: except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part') errors['line'] = _('Cannot allocate stock to a line without a part')
@ -1310,7 +1312,7 @@ class SalesOrderAllocation(models.Model):
if self.quantity <= 0: if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero') errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.item.serial and not self.quantity == 1: if self.item.serial and self.quantity != 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item') errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if self.line.order != self.shipment.order: if self.line.order != self.shipment.order:

View File

@ -2,9 +2,6 @@
JSON serializers for the Order API JSON serializers for the Order API
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal from decimal import Decimal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po.status, PurchaseOrderStatus.PLACED) self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
def test_po_metadata(self):
url = reverse('api-po-metadata', kwargs={'pk': 1})
self.patch(
url,
{
'metadata': {
'yam': 'yum',
}
},
expected_code=200
)
order = models.PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.get_metadata('yam'), 'yum')
class PurchaseOrderReceiveTest(OrderTest): class PurchaseOrderReceiveTest(OrderTest):
""" """
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
self.assertEqual(so.status, SalesOrderStatus.CANCELLED) self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
def test_so_metadata(self):
url = reverse('api-so-metadata', kwargs={'pk': 1})
self.patch(
url,
{
'metadata': {
'xyz': 'abc',
}
},
expected_code=200
)
order = models.SalesOrder.objects.get(pk=1)
self.assertEqual(order.get_metadata('xyz'), 'abc')
class SalesOrderAllocateTest(OrderTest): class SalesOrderAllocateTest(OrderTest):
""" """

View File

@ -1,8 +1,5 @@
""" Unit tests for Order views (see views.py) """ """ Unit tests for Order views (see views.py) """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model

View File

@ -2,9 +2,6 @@
Django views for interacting with Order app Django views for interacting with Order app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404

View File

@ -2,9 +2,6 @@
Provides a JSON API for the Part app Provides a JSON API for the Part app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime import datetime
from django.urls import include, path, re_path from django.urls import include, path, re_path
@ -44,6 +41,7 @@ from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from build.models import Build, BuildItem from build.models import Build, BuildItem
import order.models import order.models
from plugin.serializers import MetadataSerializer
from . import serializers as part_serializers from . import serializers as part_serializers
@ -203,6 +201,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
return response return response
class CategoryMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating PartCategory metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(PartCategory, *args, **kwargs)
queryset = PartCategory.objects.all()
class CategoryParameterList(generics.ListAPIView): class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects. """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -587,6 +594,17 @@ class PartScheduling(generics.RetrieveAPIView):
return Response(schedule) return Response(schedule)
class PartMetadata(generics.RetrieveUpdateAPIView):
"""
API endpoint for viewing / updating Part metadata
"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(Part, *args, **kwargs)
queryset = Part.objects.all()
class PartSerialNumberDetail(generics.RetrieveAPIView): class PartSerialNumberDetail(generics.RetrieveAPIView):
""" """
API endpoint for returning extra serial number information about a particular part API endpoint for returning extra serial number information about a particular part
@ -1912,7 +1930,15 @@ part_api_urls = [
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'), re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'), re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), # Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
# PartCategory detail endpoint
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
])),
path('', CategoryList.as_view(), name='api-part-category-list'), path('', CategoryList.as_view(), name='api-part-category-list'),
])), ])),
@ -1973,6 +1999,9 @@ part_api_urls = [
# Endpoint for validating a BOM for the specific Part # Endpoint for validating a BOM for the specific Part
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'), re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part detail endpoint # Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'), re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])), ])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Part objects Django Forms for interacting with Part objects
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 08:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0075_auto_20211128_0151'),
]
operations = [
migrations.AddField(
model_name='part',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='partcategory',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -2,8 +2,6 @@
Part database model definitions Part database model definitions
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal import decimal
import os import os
@ -46,29 +44,29 @@ from common.models import InvenTreeSetting
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
import common.models
from build import models as BuildModels from build import models as BuildModels
from order import models as OrderModels from order import models as OrderModels
from company.models import SupplierPart from company.models import SupplierPart
import part.settings as part_settings
from stock import models as StockModels from stock import models as StockModels
import common.models from plugin.models import MetadataMixin
import part.settings as part_settings
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree): class PartCategory(MetadataMixin, InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects. """ PartCategory provides hierarchical organization of Part objects.
Attributes: Attributes:
@ -327,7 +325,7 @@ class PartManager(TreeManager):
@cleanup.ignore @cleanup.ignore
class Part(MPTTModel): class Part(MetadataMixin, MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity. """ The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately. An actual physical instance of a Part is a StockItem which is treated separately.
@ -444,7 +442,7 @@ class Part(MPTTModel):
previous = Part.objects.get(pk=self.pk) previous = Part.objects.get(pk=self.pk)
# Image has been changed # Image has been changed
if previous.image is not None and not self.image == previous.image: if previous.image is not None and self.image != previous.image:
# Are there any (other) parts which reference the image? # Are there any (other) parts which reference the image?
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count() n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
@ -2895,7 +2893,7 @@ class BomItem(models.Model, DataImportMixin):
# If the sub_part is 'trackable' then the 'quantity' field must be an integer # If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable: if self.sub_part.trackable:
if not self.quantity == int(self.quantity): if self.quantity != int(self.quantity):
raise ValidationError({ raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts") "quantity": _("Quantity must be integer value for trackable parts")
}) })

View File

@ -2,9 +2,6 @@
User-configurable settings for the Part app User-configurable settings for the Part app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from common.models import InvenTreeSetting from common.models import InvenTreeSetting

View File

@ -589,32 +589,15 @@
// Get a list of the selected BOM items // Get a list of the selected BOM items
var rows = $("#bom-table").bootstrapTable('getSelections'); var rows = $("#bom-table").bootstrapTable('getSelections');
// TODO - In the future, display (in the dialog) which items are going to be deleted if (rows.length == 0) {
rows = $('#bom-table').bootstrapTable('getData');
}
showQuestionDialog( deleteBomItems(rows, {
'{% trans "Delete selected BOM items?" %}', success: function() {
'{% trans "All selected BOM items will be deleted" %}', $('#bom-table').bootstrapTable('refresh');
{
accept: function() {
// Keep track of each DELETE request
var requests = [];
rows.forEach(function(row) {
requests.push(
inventreeDelete(
`/api/bom/${row.pk}/`,
)
);
});
// Wait for *all* the requests to complete
$.when.apply($, requests).done(function() {
location.reload();
});
}
} }
); });
}); });
$('#bom-upload').click(function() { $('#bom-upload').click(function() {

View File

@ -21,6 +21,85 @@ import build.models
import order.models import order.models
class PartCategoryAPITest(InvenTreeAPITestCase):
"""Unit tests for the PartCategory API"""
fixtures = [
'category',
'part',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def test_category_list(self):
# List all part categories
url = reverse('api-part-category-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 8)
# Filter by parent, depth=1
response = self.get(
url,
{
'parent': 1,
'cascade': False,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
# Filter by parent, cascading
response = self.get(
url,
{
'parent': 1,
'cascade': True,
},
expected_code=200,
)
self.assertEqual(len(response.data), 5)
def test_category_metadata(self):
"""Test metadata endpoint for the PartCategory"""
cat = PartCategory.objects.get(pk=1)
cat.metadata = {
'foo': 'bar',
'water': 'melon',
'abc': 'xyz',
}
cat.set_metadata('abc', 'ABC')
response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200)
metadata = response.data['metadata']
self.assertEqual(metadata['foo'], 'bar')
self.assertEqual(metadata['water'], 'melon')
self.assertEqual(metadata['abc'], 'ABC')
class PartOptionsAPITest(InvenTreeAPITestCase): class PartOptionsAPITest(InvenTreeAPITestCase):
""" """
Tests for the various OPTIONS endpoints in the /part/ API Tests for the various OPTIONS endpoints in the /part/ API
@ -1021,6 +1100,59 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 9000) self.assertEqual(data['in_stock'], 9000)
self.assertEqual(data['unallocated_stock'], 9000) self.assertEqual(data['unallocated_stock'], 9000)
def test_part_metadata(self):
"""
Tests for the part metadata endpoint
"""
url = reverse('api-part-metadata', kwargs={'pk': 1})
part = Part.objects.get(pk=1)
# Metadata is initially null
self.assertIsNone(part.metadata)
part.metadata = {'foo': 'bar'}
part.save()
response = self.get(url, expected_code=200)
self.assertEqual(response.data['metadata']['foo'], 'bar')
# Add more data via the API
# Using the 'patch' method causes the new data to be merged in
self.patch(
url,
{
'metadata': {
'hello': 'world',
}
},
expected_code=200
)
part.refresh_from_db()
self.assertEqual(part.metadata['foo'], 'bar')
self.assertEqual(part.metadata['hello'], 'world')
# Now, issue a PUT request (existing data will be replacted)
self.put(
url,
{
'metadata': {
'x': 'y'
},
},
expected_code=200
)
part.refresh_from_db()
self.assertFalse('foo' in part.metadata)
self.assertFalse('hello' in part.metadata)
self.assertEqual(part.metadata['x'], 'y')
class PartAPIAggregationTest(InvenTreeAPITestCase): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """

View File

@ -1,8 +1,5 @@
# Tests for Part Parameters # Tests for Part Parameters
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions

View File

@ -199,7 +199,7 @@ class PartTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
part_2.validate_unique() part_2.validate_unique()
def test_metadata(self): def test_attributes(self):
self.assertEqual(self.r1.name, 'R_2K2_0805') self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/') self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -245,6 +245,24 @@ class PartTest(TestCase):
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08) self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5) self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
def test_metadata(self):
"""Unit tests for the Part metadata field"""
p = Part.objects.get(pk=1)
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class TestTemplateTest(TestCase): class TestTemplateTest(TestCase):

View File

@ -2,9 +2,6 @@
Django views for interacting with Part app Django views for interacting with Part app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
@ -628,7 +625,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
self.response = response self.response = response
# Check for valid response code # Check for valid response code
if not response.status_code == 200: if response.status_code != 200:
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code)) form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return return

View File

@ -2,15 +2,10 @@
JSON API for the plugin app JSON API for the plugin app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.urls import include, re_path from django.urls import include, re_path
from rest_framework import generics from rest_framework import filters, generics, permissions, status
from rest_framework import status
from rest_framework import permissions
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
@ -19,6 +14,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from common.api import GlobalSettingsPermissions from common.api import GlobalSettingsPermissions
from plugin.base.barcodes.api import barcode_api_urls from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.action.api import ActionPluginView from plugin.base.action.api import ActionPluginView
from plugin.base.locate.api import LocatePluginView
from plugin.models import PluginConfig, PluginSetting from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers import plugin.serializers as PluginSerializers
from plugin.registry import registry from plugin.registry import registry
@ -38,6 +34,35 @@ class PluginList(generics.ListAPIView):
serializer_class = PluginSerializers.PluginConfigSerializer serializer_class = PluginSerializers.PluginConfigSerializer
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter plugins which support a given mixin
mixin = params.get('mixin', None)
if mixin:
matches = []
for result in queryset:
if mixin in result.mixins().keys():
matches.append(result.pk)
queryset = queryset.filter(pk__in=matches)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'active',
]
ordering_fields = [ ordering_fields = [
'key', 'key',
'name', 'name',
@ -163,6 +188,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin_api_urls = [ plugin_api_urls = [
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'^barcode/', include(barcode_api_urls)),
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
] ]
general_plugin_api_urls = [ general_plugin_api_urls = [

View File

@ -31,12 +31,8 @@ class ActionPluginView(APIView):
action_plugins = registry.with_mixin('action') action_plugins = registry.with_mixin('action')
for plugin in action_plugins: for plugin in action_plugins:
if plugin.action_name() == action: if plugin.action_name() == action:
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released plugin.perform_action(request.user, data=data)
plugin.init(request.user, data=data) return Response(plugin.get_response(request.user, data=data))
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found # If we got to here, no matching action was found
return Response({ return Response({

View File

@ -15,10 +15,9 @@ class ActionMixin:
""" """
MIXIN_NAME = 'Actions' MIXIN_NAME = 'Actions'
def __init__(self, user=None, data=None): def __init__(self):
super().__init__() super().__init__()
self.add_mixin('action', True, __class__) self.add_mixin('action', True, __class__)
self.init(user, data)
def action_name(self): def action_name(self):
""" """
@ -31,19 +30,12 @@ class ActionMixin:
return self.ACTION_NAME return self.ACTION_NAME
return self.name return self.name
def init(self, user, data=None): def perform_action(self, user=None, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
self.user = user
self.data = data
def perform_action(self):
""" """
Override this method to perform the action! Override this method to perform the action!
""" """
def get_result(self): def get_result(self, user=None, data=None):
""" """
Result of the action? Result of the action?
""" """
@ -51,19 +43,19 @@ class ActionMixin:
# Re-implement this for cutsom actions # Re-implement this for cutsom actions
return False return False
def get_info(self): def get_info(self, user=None, data=None):
""" """
Extra info? Can be a string / dict / etc Extra info? Can be a string / dict / etc
""" """
return None return None
def get_response(self): def get_response(self, user=None, data=None):
""" """
Return a response. Default implementation is a simple response Return a response. Default implementation is a simple response
which can be overridden. which can be overridden.
""" """
return { return {
"action": self.action_name(), "action": self.action_name(),
"result": self.get_result(), "result": self.get_result(user, data),
"info": self.get_info(), "info": self.get_info(user, data),
} }

View File

@ -14,27 +14,27 @@ class ActionMixinTests(TestCase):
def setUp(self): def setUp(self):
class SimplePlugin(ActionMixin, InvenTreePlugin): class SimplePlugin(ActionMixin, InvenTreePlugin):
pass pass
self.plugin = SimplePlugin('user') self.plugin = SimplePlugin()
class TestActionPlugin(ActionMixin, InvenTreePlugin): class TestActionPlugin(ActionMixin, InvenTreePlugin):
"""a action plugin""" """a action plugin"""
ACTION_NAME = 'abc123' ACTION_NAME = 'abc123'
def perform_action(self): def perform_action(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'action' return ActionMixinTests.ACTION_RETURN + 'action'
def get_result(self): def get_result(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'result' return ActionMixinTests.ACTION_RETURN + 'result'
def get_info(self): def get_info(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'info' return ActionMixinTests.ACTION_RETURN + 'info'
self.action_plugin = TestActionPlugin('user') self.action_plugin = TestActionPlugin()
class NameActionPlugin(ActionMixin, InvenTreePlugin): class NameActionPlugin(ActionMixin, InvenTreePlugin):
NAME = 'Aplugin' NAME = 'Aplugin'
self.action_name = NameActionPlugin('user') self.action_name = NameActionPlugin()
def test_action_name(self): def test_action_name(self):
"""check the name definition possibilities""" """check the name definition possibilities"""

View File

@ -63,7 +63,6 @@ class BarcodeScan(APIView):
plugin = None plugin = None
for current_plugin in plugins: for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data) current_plugin.init(barcode_data)
if current_plugin.validate(): if current_plugin.validate():
@ -168,7 +167,6 @@ class BarcodeAssign(APIView):
plugin = None plugin = None
for current_plugin in plugins: for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data) current_plugin.init(barcode_data)
if current_plugin.validate(): if current_plugin.validate():

View File

@ -2,9 +2,6 @@
Functions for triggering and responding to server side events Functions for triggering and responding to server side events
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging import logging
from django.conf import settings from django.conf import settings

View File

@ -0,0 +1,82 @@
"""API for location plugins"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import permissions
from rest_framework.exceptions import ParseError, NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.tasks import offload_task
from plugin import registry
from stock.models import StockItem, StockLocation
class LocatePluginView(APIView):
"""
Endpoint for using a custom plugin to identify or 'locate' a stock item or location
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
# Which plugin to we wish to use?
plugin = request.data.get('plugin', None)
if not plugin:
raise ParseError("'plugin' field must be supplied")
# Check that the plugin exists, and supports the 'locate' mixin
plugins = registry.with_mixin('locate')
if plugin not in [p.slug for p in plugins]:
raise ParseError(f"Plugin '{plugin}' is not installed, or does not support the location mixin")
# StockItem to identify
item_pk = request.data.get('item', None)
# StockLocation to identify
location_pk = request.data.get('location', None)
if not item_pk and not location_pk:
raise ParseError("Must supply either 'item' or 'location' parameter")
data = {
"success": "Identification plugin activated",
"plugin": plugin,
}
# StockItem takes priority
if item_pk:
try:
StockItem.objects.get(pk=item_pk)
offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk)
data['item'] = item_pk
return Response(data)
except StockItem.DoesNotExist:
raise NotFound("StockItem matching PK '{item}' not found")
elif location_pk:
try:
StockLocation.objects.get(pk=location_pk)
offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk)
data['location'] = location_pk
return Response(data)
except StockLocation.DoesNotExist:
raise NotFound("StockLocation matching PK {'location'} not found")
else:
raise NotFound()

View File

@ -0,0 +1,74 @@
"""Plugin mixin for locating stock items and locations"""
import logging
from plugin.helpers import MixinImplementationError
logger = logging.getLogger('inventree')
class LocateMixin:
"""
Mixin class which provides support for 'locating' inventory items,
for example identifying the location of a particular StockLocation.
Plugins could implement audible or visual cues to direct attention to the location,
with (for e.g.) LED strips or buzzers, or some other method.
The plugins may also be used to *deliver* a particular stock item to the user.
A class which implements this mixin may implement the following methods:
- locate_stock_item : Used to locate / identify a particular stock item
- locate_stock_location : Used to locate / identify a particular stock location
Refer to the default method implementations below for more information!
"""
class MixinMeta:
MIXIN_NAME = "Locate"
def __init__(self):
super().__init__()
self.add_mixin('locate', True, __class__)
def locate_stock_item(self, item_pk):
"""
Attempt to locate a particular StockItem
Arguments:
item_pk: The PK (primary key) of the StockItem to be located
The default implementation for locating a StockItem
attempts to locate the StockLocation where the item is located.
An attempt is only made if the StockItem is *in stock*
Note: A custom implemenation could always change this behaviour
"""
logger.info(f"LocateMixin: Attempting to locate StockItem pk={item_pk}")
from stock.models import StockItem
try:
item = StockItem.objects.get(pk=item_pk)
if item.in_stock and item.location is not None:
self.locate_stock_location(item.location.pk)
except StockItem.DoesNotExist:
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
pass
def locate_stock_location(self, location_pk):
"""
Attempt to location a particular StockLocation
Arguments:
location_pk: The PK (primary key) of the StockLocation to be located
Note: The default implementation here does nothing!
"""
raise MixinImplementationError

View File

@ -13,14 +13,14 @@ class SimpleActionPlugin(ActionMixin, InvenTreePlugin):
NAME = "SimpleActionPlugin" NAME = "SimpleActionPlugin"
ACTION_NAME = "simple" ACTION_NAME = "simple"
def perform_action(self): def perform_action(self, user=None, data=None):
print("Action plugin in action!") print("Action plugin in action!")
def get_info(self): def get_info(self, user, data=None):
return { return {
"user": self.user.username, "user": user.username,
"hello": "world", "hello": "world",
} }
def get_result(self): def get_result(self, user=None, data=None):
return True return True

View File

@ -15,7 +15,7 @@ class SimpleActionPluginTests(TestCase):
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password') self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
self.plugin = SimpleActionPlugin(user=self.test_user) self.plugin = SimpleActionPlugin()
def test_name(self): def test_name(self):
"""check plugn names """ """check plugn names """

View File

@ -2,8 +2,10 @@
Import helper for events Import helper for events
""" """
from plugin.base.event.events import trigger_event from plugin.base.event.events import process_event, register_event, trigger_event
__all__ = [ __all__ = [
'process_event',
'register_event',
'trigger_event', 'trigger_event',
] ]

View File

@ -10,6 +10,7 @@ from ..base.action.mixins import ActionMixin
from ..base.barcodes.mixins import BarcodeMixin from ..base.barcodes.mixins import BarcodeMixin
from ..base.event.mixins import EventMixin from ..base.event.mixins import EventMixin
from ..base.label.mixins import LabelPrintingMixin from ..base.label.mixins import LabelPrintingMixin
from ..base.locate.mixins import LocateMixin
__all__ = [ __all__ = [
'APICallMixin', 'APICallMixin',
@ -23,6 +24,7 @@ __all__ = [
'PanelMixin', 'PanelMixin',
'ActionMixin', 'ActionMixin',
'BarcodeMixin', 'BarcodeMixin',
'LocateMixin',
'SingleNotificationMethod', 'SingleNotificationMethod',
'BulkNotificationMethod', 'BulkNotificationMethod',
] ]

View File

@ -2,8 +2,6 @@
Plugin model definitions Plugin model definitions
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import warnings import warnings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -16,6 +14,65 @@ import common.models
from plugin import InvenTreePlugin, registry from plugin import InvenTreePlugin, registry
class MetadataMixin(models.Model):
"""
Model mixin class which adds a JSON metadata field to a model,
for use by any (and all) plugins.
The intent of this mixin is to provide a metadata field on a model instance,
for plugins to read / modify as required, to store any extra information.
The assumptions for models implementing this mixin are:
- The internal InvenTree business logic will make no use of this field
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
"""
class Meta:
abstract = True
metadata = models.JSONField(
blank=True, null=True,
verbose_name=_('Plugin Metadata'),
help_text=_('JSON metadata field, for use by external plugins'),
)
def get_metadata(self, key: str, backup_value=None):
"""
Finds metadata for this model instance, using the provided key for lookup
Args:
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
Returns:
Python dict object containing requested metadata. If no matching metadata is found, returns None
"""
if self.metadata is None:
return backup_value
return self.metadata.get(key, backup_value)
def set_metadata(self, key: str, data, commit=True):
"""
Save the provided metadata under the provided key.
Args:
key: String key for saving metadata
data: Data object to save - must be able to be rendered as a JSON string
overwrite: If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted
"""
if self.metadata is None:
# Handle a null field value
self.metadata = {}
self.metadata[key] = data
if commit:
self.save()
class PluginConfig(models.Model): class PluginConfig(models.Model):
""" """
A PluginConfig object holds settings for plugins. A PluginConfig object holds settings for plugins.

View File

@ -133,7 +133,6 @@ class PluginsRegistry:
if retry_counter <= 0: # pragma: no cover if retry_counter <= 0: # pragma: no cover
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
print('[PLUGIN] Max retries, breaking loading') print('[PLUGIN] Max retries, breaking loading')
# TODO error for server status
break break
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left') print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
@ -301,7 +300,6 @@ class PluginsRegistry:
# Errors are bad so disable the plugin in the database # Errors are bad so disable the plugin in the database
if not settings.PLUGIN_TESTING: # pragma: no cover if not settings.PLUGIN_TESTING: # pragma: no cover
plugin_db_setting.active = False plugin_db_setting.active = False
# TODO save the error to the plugin
plugin_db_setting.save(no_reload=True) plugin_db_setting.save(no_reload=True)
# Add to inactive plugins so it shows up in the ui # Add to inactive plugins so it shows up in the ui
@ -310,8 +308,6 @@ class PluginsRegistry:
# Initialize package # Initialize package
# now we can be sure that an admin has activated the plugin # now we can be sure that an admin has activated the plugin
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
logger.info(f'Loading plugin {plug_name}') logger.info(f'Loading plugin {plug_name}')
try: try:

View File

@ -0,0 +1,38 @@
"""
Sample plugin for locating stock items / locations.
Note: This plugin does not *actually* locate anything!
"""
import logging
from plugin import InvenTreePlugin
from plugin.mixins import LocateMixin
logger = logging.getLogger('inventree')
class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
"""
A very simple example of the 'locate' plugin.
This plugin class simply prints location information to the logger.
"""
NAME = "SampleLocatePlugin"
SLUG = "samplelocate"
TITLE = "Sample plugin for locating items"
VERSION = "0.1"
def locate_stock_location(self, location_pk):
from stock.models import StockLocation
logger.info(f"SampleLocatePlugin attempting to locate location ID {location_pk}")
try:
location = StockLocation.objects.get(pk=location_pk)
logger.info(f"Location exists at '{location.pathstring}'")
except StockLocation.DoesNotExist:
logger.error(f"Location ID {location_pk} does not exist!")

View File

@ -2,9 +2,6 @@
JSON serializers for plugin app JSON serializers for plugin app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import subprocess import subprocess
@ -19,6 +16,34 @@ from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
from common.serializers import GenericReferencedSettingSerializer from common.serializers import GenericReferencedSettingSerializer
class MetadataSerializer(serializers.ModelSerializer):
"""
Serializer class for model metadata API access.
"""
metadata = serializers.JSONField(required=True)
def __init__(self, model_type, *args, **kwargs):
self.Meta.model = model_type
super().__init__(*args, **kwargs)
class Meta:
fields = [
'metadata',
]
def update(self, instance, data):
if self.partial:
# Default behaviour is to "merge" new data in
metadata = instance.metadata.copy() if instance.metadata else {}
metadata.update(data['metadata'])
data['metadata'] = metadata
return super().update(instance, data)
class PluginConfigSerializer(serializers.ModelSerializer): class PluginConfigSerializer(serializers.ModelSerializer):
""" """
Serializer for a PluginConfig: Serializer for a PluginConfig:

View File

@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
return plugin.mixin_enabled(key) return plugin.mixin_enabled(key)
@register.simple_tag()
def mixin_available(mixin, *args, **kwargs):
"""
Returns True if there is at least one active plugin which supports the provided mixin
"""
return len(registry.with_mixin(mixin)) > 0
@register.simple_tag() @register.simple_tag()
def navigation_enabled(*args, **kwargs): def navigation_enabled(*args, **kwargs):
""" """

View File

@ -87,7 +87,7 @@ class InvenTreePluginTests(TestCase):
AUTHOR = 'AA BB' AUTHOR = 'AA BB'
DESCRIPTION = 'A description' DESCRIPTION = 'A description'
VERSION = '1.2.3a' VERSION = '1.2.3a'
WEBSITE = 'http://aa.bb/cc' WEBSITE = 'https://aa.bb/cc'
LICENSE = 'MIT' LICENSE = 'MIT'
self.plugin_name = NameInvenTreePlugin() self.plugin_name = NameInvenTreePlugin()
@ -147,7 +147,7 @@ class InvenTreePluginTests(TestCase):
# website # website
self.assertEqual(self.plugin.website, None) self.assertEqual(self.plugin.website, None)
self.assertEqual(self.plugin_simple.website, None) self.assertEqual(self.plugin_simple.website, None)
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc') self.assertEqual(self.plugin_name.website, 'https://aa.bb/cc')
# license # license
self.assertEqual(self.plugin.license, None) self.assertEqual(self.plugin.license, None)

View File

@ -2,9 +2,6 @@
Report template model definitions Report template model definitions
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import sys import sys
import logging import logging
@ -389,7 +386,7 @@ class BuildReport(ReportTemplateBase):
my_build = self.object_to_print my_build = self.object_to_print
if not type(my_build) == build.models.Build: if type(my_build) != build.models.Build:
raise TypeError('Provided model is not a Build object') raise TypeError('Provided model is not a Build object')
return { return {

View File

@ -2,9 +2,6 @@
JSON API for the Stock app JSON API for the Stock app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -43,6 +40,8 @@ from order.serializers import PurchaseOrderSerializer
from part.models import BomItem, Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from plugin.serializers import MetadataSerializer
from stock.admin import StockItemResource from stock.admin import StockItemResource
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from stock.models import StockItemTracking from stock.models import StockItemTracking
@ -92,6 +91,15 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
class StockMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating StockItem metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(StockItem, *args, **kwargs)
queryset = StockItem.objects.all()
class StockItemContextMixin: class StockItemContextMixin:
""" Mixin class for adding StockItem object to serializer context """ """ Mixin class for adding StockItem object to serializer context """
@ -1368,6 +1376,15 @@ class StockTrackingList(generics.ListAPIView):
] ]
class LocationMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating StockLocation metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(StockLocation, *args, **kwargs)
queryset = StockLocation.objects.all()
class LocationDetail(generics.RetrieveUpdateDestroyAPIView): class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of StockLocation object """ API endpoint for detail view of StockLocation object
@ -1385,7 +1402,14 @@ stock_api_urls = [
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'), re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
re_path(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'), # Stock location detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'),
])),
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'), re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
])), ])),
@ -1417,8 +1441,9 @@ stock_api_urls = [
# Detail views for a single stock item # Detail views for a single stock item
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'), re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])), ])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Stock app Django Forms for interacting with Stock app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import StockItem, StockItemTracking from .models import StockItem, StockItemTracking

Some files were not shown because too many files have changed in this diff Show More