mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into not-working-tests
This commit is contained in:
commit
8340daf77b
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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"
|
||||
|
@ -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"))
|
||||
|
||||
|
@ -2,9 +2,6 @@
|
||||
Serializers used in various InvenTree apps
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import tablib
|
||||
|
||||
|
@ -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 = []
|
||||
|
||||
|
@ -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}'")
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
})
|
||||
|
@ -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 _
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 _
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -1,6 +1,3 @@
|
||||
"""
|
||||
Unit tests for the views associated with the 'common' app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
@ -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):
|
||||
|
@ -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 _
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"),
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -2,9 +2,6 @@
|
||||
Label printing models
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
@ -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
|
||||
|
@ -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
@ -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'),
|
||||
])),
|
||||
|
||||
|
@ -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 _
|
||||
|
||||
|
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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 _
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
@ -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 _
|
||||
|
||||
|
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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")
|
||||
})
|
||||
|
@ -2,9 +2,6 @@
|
||||
User-configurable settings for the Part app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 = [
|
||||
|
@ -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({
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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"""
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
|
82
InvenTree/plugin/base/locate/api.py
Normal file
82
InvenTree/plugin/base/locate/api.py
Normal 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()
|
74
InvenTree/plugin/base/locate/mixins.py
Normal file
74
InvenTree/plugin/base/locate/mixins.py
Normal 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
|
@ -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
|
||||
|
@ -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 """
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal 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!")
|
@ -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:
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user