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
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.http import JsonResponse

View File

@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
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):
"""
Issue an OPTIONS request

View File

@ -4,11 +4,16 @@ InvenTree API version information
# 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
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
- Adds "export to file" functionality for PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint

View File

@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
update = True
# 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}")
update = True

View File

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

View File

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

View File

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

View File

@ -250,7 +250,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info = super().get_field_info(field)
# 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()
# 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)
# 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}'")
raise ValidationError(_("Invalid attachment directory"))

View File

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

View File

@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
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")
EXTRA_URL_SCHEMES = []

View File

@ -126,8 +126,8 @@ def heartbeat():
try:
from django_q.models import Success
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform heartbeat task - App registry not ready")
return
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')
if not response.status_code == 200:
if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
data = json.loads(response.text)
@ -216,13 +216,13 @@ def check_for_updates():
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")
return
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")
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).
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import json
@ -627,7 +625,7 @@ class SetPasswordView(AjaxUpdateView):
if valid:
# Passwords must match
if not p1 == p2:
if p1 != p2:
error = _('Password fields must match')
form.add_error('enter_password', error)
form.add_error('confirm_password', error)

View File

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

View File

@ -2,8 +2,6 @@
Build database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
@ -777,7 +775,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if not output.is_building:
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"))
# Unallocate all build items against the output
@ -1240,7 +1238,7 @@ class BuildItem(models.Model):
})
# 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({
'quantity': _('Quantity must be 1 for serialized stock')
})

View File

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

View File

@ -2,9 +2,6 @@
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.views.generic import DetailView, ListView

View File

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

View File

@ -199,7 +199,7 @@ class FileManager:
try:
# 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)
except ValueError:
pass

View File

@ -2,9 +2,6 @@
Django forms for interacting with common objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
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.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import decimal
import math
@ -1802,10 +1799,8 @@ class WebhookEndpoint(models.Model):
def process_webhook(self):
if self.token:
self.verify = VerificationMethod.TOKEN
# TODO make a object-setting
if self.secret:
self.verify = VerificationMethod.HMAC
# TODO make a object-setting
return True
def validate_token(self, payload, headers, request):

View File

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

View File

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

View File

@ -1,6 +1,3 @@
"""
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:
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
def test_defaults(self):

View File

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

View File

@ -2,9 +2,6 @@
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 import rest_framework as rest_filters

View File

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

View File

@ -2,9 +2,6 @@
Company database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
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!
if self.manufacturer_part and self.part:
if not self.manufacturer_part.part == self.part:
if self.manufacturer_part.part != self.part:
raise ValidationError({
'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) """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model

View File

@ -2,10 +2,6 @@
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.views.generic import DetailView, ListView
@ -162,7 +158,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
self.response = response
# 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))
return

View File

@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same",
# 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}'")
to_copy = True
@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same",
# 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}'")
to_copy = True
@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
if os.path.exists(dst_file):
# 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}'")
to_copy = True

View File

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

View File

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

View File

@ -1,8 +1,5 @@
# Tests for labels
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
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
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.urls import include, path, re_path
from django.db.models import Q, F
@ -27,6 +24,8 @@ import order.serializers as serializers
from part.models import Part
from users.models import Owner
from plugin.serializers import MetadataSerializer
class GeneralExtraLineList:
"""
@ -347,6 +346,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
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):
"""
API endpoint to receive stock items against a purchase order.
@ -916,6 +924,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
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):
"""
API endpoint to allocation stock items against a SalesOrder,
@ -1138,10 +1155,13 @@ order_api_urls = [
# Individual purchase order detail URLs
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'^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'),
])),
@ -1178,10 +1198,13 @@ order_api_urls = [
# Sales order detail view
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-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'),
])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Order objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
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 stock import models as stock_models
from company.models import Company, SupplierPart
from plugin.events import trigger_event
from plugin.models import MetadataMixin
import InvenTree.helpers
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
@ -97,7 +99,7 @@ def get_next_so_number():
return reference
class Order(ReferenceIndexingMixin):
class Order(MetadataMixin, ReferenceIndexingMixin):
""" Abstract model for an order.
Instances of this class:
@ -306,7 +308,7 @@ class PurchaseOrder(Order):
except ValueError:
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")})
if group:
@ -445,7 +447,7 @@ class PurchaseOrder(Order):
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
"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
"""
if not self.status == SalesOrderStatus.PENDING:
if self.status != SalesOrderStatus.PENDING:
return False
return True
@ -1295,7 +1297,7 @@ class SalesOrderAllocation(models.Model):
raise ValidationError({'item': _('Stock item has not been assigned')})
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')
except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part')
@ -1310,7 +1312,7 @@ class SalesOrderAllocation(models.Model):
if self.quantity <= 0:
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')
if self.line.order != self.shipment.order:

View File

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

View File

@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
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):
"""
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
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):
"""

View File

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

View File

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

View File

@ -2,9 +2,6 @@
Provides a JSON API for the Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from django.urls import include, path, re_path
@ -44,6 +41,7 @@ from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build, BuildItem
import order.models
from plugin.serializers import MetadataSerializer
from . import serializers as part_serializers
@ -203,6 +201,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
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):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -587,6 +594,17 @@ class PartScheduling(generics.RetrieveAPIView):
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):
"""
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'^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'),
])),
@ -1973,6 +1999,9 @@ part_api_urls = [
# Endpoint for validating a BOM for the specific Part
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
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Part objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
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
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
@ -46,29 +44,29 @@ from common.models import InvenTreeSetting
from InvenTree import helpers
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.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
import common.models
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
import part.settings as part_settings
from stock import models as StockModels
import common.models
import part.settings as part_settings
from plugin.models import MetadataMixin
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):
class PartCategory(MetadataMixin, InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
Attributes:
@ -327,7 +325,7 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(MPTTModel):
class Part(MetadataMixin, MPTTModel):
""" 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.
@ -444,7 +442,7 @@ class Part(MPTTModel):
previous = Part.objects.get(pk=self.pk)
# 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?
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 self.sub_part.trackable:
if not self.quantity == int(self.quantity):
if self.quantity != int(self.quantity):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})

View File

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

View File

@ -589,32 +589,15 @@
// Get a list of the selected BOM items
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(
'{% trans "Delete selected BOM items?" %}',
'{% trans "All selected BOM items will be deleted" %}',
{
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();
});
}
deleteBomItems(rows, {
success: function() {
$('#bom-table').bootstrapTable('refresh');
}
);
});
});
$('#bom-upload').click(function() {

View File

@ -21,6 +21,85 @@ import build.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):
"""
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['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):
"""

View File

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

View File

@ -199,7 +199,7 @@ class PartTest(TestCase):
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self):
def test_attributes(self):
self.assertEqual(self.r1.name, 'R_2K2_0805')
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(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):

View File

@ -2,9 +2,6 @@
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.exceptions import ValidationError
from django.db import transaction
@ -628,7 +625,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
self.response = response
# 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))
return

View File

@ -2,15 +2,10 @@
JSON API for the plugin app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.urls import include, re_path
from rest_framework import generics
from rest_framework import status
from rest_framework import permissions
from rest_framework import filters, generics, permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
@ -19,6 +14,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from common.api import GlobalSettingsPermissions
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.action.api import ActionPluginView
from plugin.base.locate.api import LocatePluginView
from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers
from plugin.registry import registry
@ -38,6 +34,35 @@ class PluginList(generics.ListAPIView):
serializer_class = PluginSerializers.PluginConfigSerializer
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 = [
'key',
'name',
@ -163,6 +188,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin_api_urls = [
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'),
]
general_plugin_api_urls = [

View File

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

View File

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

View File

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

View File

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

View File

@ -2,9 +2,6 @@
Functions for triggering and responding to server side events
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
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"
ACTION_NAME = "simple"
def perform_action(self):
def perform_action(self, user=None, data=None):
print("Action plugin in action!")
def get_info(self):
def get_info(self, user, data=None):
return {
"user": self.user.username,
"user": user.username,
"hello": "world",
}
def get_result(self):
def get_result(self, user=None, data=None):
return True

View File

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

View File

@ -2,8 +2,10 @@
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__ = [
'process_event',
'register_event',
'trigger_event',
]

View File

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

View File

@ -2,8 +2,6 @@
Plugin model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import warnings
from django.utils.translation import gettext_lazy as _
@ -16,6 +14,65 @@ import common.models
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):
"""
A PluginConfig object holds settings for plugins.

View File

@ -133,7 +133,6 @@ class PluginsRegistry:
if retry_counter <= 0: # pragma: no cover
if settings.PLUGIN_TESTING:
print('[PLUGIN] Max retries, breaking loading')
# TODO error for server status
break
if settings.PLUGIN_TESTING:
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
if not settings.PLUGIN_TESTING: # pragma: no cover
plugin_db_setting.active = False
# TODO save the error to the plugin
plugin_db_setting.save(no_reload=True)
# Add to inactive plugins so it shows up in the ui
@ -310,8 +308,6 @@ class PluginsRegistry:
# Initialize package
# 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}')
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
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import subprocess
@ -19,6 +16,34 @@ from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
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):
"""
Serializer for a PluginConfig:

View File

@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
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()
def navigation_enabled(*args, **kwargs):
"""

View File

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

View File

@ -2,9 +2,6 @@
Report template model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import sys
import logging
@ -389,7 +386,7 @@ class BuildReport(ReportTemplateBase):
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')
return {

View File

@ -2,9 +2,6 @@
JSON API for the Stock app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from collections import OrderedDict
from datetime import datetime, timedelta
@ -43,6 +40,8 @@ from order.serializers import PurchaseOrderSerializer
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from plugin.serializers import MetadataSerializer
from stock.admin import StockItemResource
from stock.models import StockLocation, StockItem
from stock.models import StockItemTracking
@ -92,6 +91,15 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
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:
""" 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):
""" 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'^(?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'),
])),
@ -1417,8 +1441,9 @@ stock_api_urls = [
# Detail views for a single stock item
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'^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'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])),

View File

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

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