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
|
Main JSON interface views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def put(self, url, data, expected_code=None, format='json'):
|
||||||
|
"""
|
||||||
|
Issue a PUT request
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.put(url, data=data, format=format)
|
||||||
|
|
||||||
|
if expected_code is not None:
|
||||||
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def options(self, url, expected_code=None):
|
def options(self, url, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue an OPTIONS request
|
Issue an OPTIONS request
|
||||||
|
@ -4,11 +4,16 @@ InvenTree API version information
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 48
|
INVENTREE_API_VERSION = 49
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
|
||||||
|
- Allows filtering of plugin list by 'active' status
|
||||||
|
- Allows filtering of plugin list by 'mixin' support
|
||||||
|
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
|
||||||
|
|
||||||
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
||||||
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
||||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||||
|
@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
update = True
|
update = True
|
||||||
|
|
||||||
# Backend currency has changed?
|
# Backend currency has changed?
|
||||||
if not base_currency == backend.base_currency:
|
if base_currency != backend.base_currency:
|
||||||
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
""" Custom fields used in InvenTree """
|
""" Custom fields used in InvenTree """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .validators import allowable_url_schemes
|
from .validators import allowable_url_schemes
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
Helper forms which subclass Django forms to provide additional functionality
|
Helper forms which subclass Django forms to provide additional functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -224,7 +224,7 @@ def increment(n):
|
|||||||
groups = result.groups()
|
groups = result.groups()
|
||||||
|
|
||||||
# If we cannot match the regex, then simply return the provided value
|
# If we cannot match the regex, then simply return the provided value
|
||||||
if not len(groups) == 2:
|
if len(groups) != 2:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
prefix, number = groups
|
prefix, number = groups
|
||||||
@ -536,7 +536,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
raise ValidationError([_("No serial numbers found")])
|
raise ValidationError([_("No serial numbers found")])
|
||||||
|
|
||||||
# The number of extracted serial numbers must match the expected quantity
|
# The number of extracted serial numbers must match the expected quantity
|
||||||
if not expected_quantity == len(numbers):
|
if expected_quantity != len(numbers):
|
||||||
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
||||||
|
|
||||||
return numbers
|
return numbers
|
||||||
@ -575,7 +575,7 @@ def validateFilterString(value, model=None):
|
|||||||
|
|
||||||
pair = group.split('=')
|
pair = group.split('=')
|
||||||
|
|
||||||
if not len(pair) == 2:
|
if len(pair) != 2:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Invalid group: {g}".format(g=group)
|
"Invalid group: {g}".format(g=group)
|
||||||
)
|
)
|
||||||
|
@ -250,7 +250,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
field_info = super().get_field_info(field)
|
field_info = super().get_field_info(field)
|
||||||
|
|
||||||
# If a default value is specified for the serializer field, add it!
|
# If a default value is specified for the serializer field, add it!
|
||||||
if 'default' not in field_info and not field.default == empty:
|
if 'default' not in field_info and field.default != empty:
|
||||||
field_info['default'] = field.get_default()
|
field_info['default'] = field.get_default()
|
||||||
|
|
||||||
# Force non-nullable fields to read as "required"
|
# Force non-nullable fields to read as "required"
|
||||||
|
@ -259,7 +259,7 @@ class InvenTreeAttachment(models.Model):
|
|||||||
new_file = os.path.abspath(new_file)
|
new_file = os.path.abspath(new_file)
|
||||||
|
|
||||||
# Check that there are no directory tricks going on...
|
# Check that there are no directory tricks going on...
|
||||||
if not os.path.dirname(new_file) == attachment_dir:
|
if os.path.dirname(new_file) != attachment_dir:
|
||||||
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
||||||
raise ValidationError(_("Invalid attachment directory"))
|
raise ValidationError(_("Invalid attachment directory"))
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Serializers used in various InvenTree apps
|
Serializers used in various InvenTree apps
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tablib
|
import tablib
|
||||||
|
|
||||||
|
@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
||||||
|
|
||||||
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover
|
if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
|
||||||
logger.warning("extra_url_schemes not correctly formatted")
|
logger.warning("extra_url_schemes not correctly formatted")
|
||||||
EXTRA_URL_SCHEMES = []
|
EXTRA_URL_SCHEMES = []
|
||||||
|
|
||||||
|
@ -126,8 +126,8 @@ def heartbeat():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
|
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
threshold = timezone.now() - timedelta(minutes=30)
|
threshold = timezone.now() - timedelta(minutes=30)
|
||||||
@ -204,7 +204,7 @@ def check_for_updates():
|
|||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||||
|
|
||||||
if not response.status_code == 200:
|
if response.status_code != 200:
|
||||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
||||||
|
|
||||||
data = json.loads(response.text)
|
data = json.loads(response.text)
|
||||||
@ -216,13 +216,13 @@ def check_for_updates():
|
|||||||
|
|
||||||
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
||||||
|
|
||||||
if not len(match.groups()) == 3:
|
if len(match.groups()) != 3:
|
||||||
logger.warning(f"Version '{tag}' did not match expected pattern")
|
logger.warning(f"Version '{tag}' did not match expected pattern")
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_version = [int(x) for x in match.groups()]
|
latest_version = [int(x) for x in match.groups()]
|
||||||
|
|
||||||
if not len(latest_version) == 3:
|
if len(latest_version) != 3:
|
||||||
raise ValueError(f"Version '{tag}' is not correct format")
|
raise ValueError(f"Version '{tag}' is not correct format")
|
||||||
|
|
||||||
logger.info(f"Latest InvenTree version: '{tag}'")
|
logger.info(f"Latest InvenTree version: '{tag}'")
|
||||||
|
@ -5,8 +5,6 @@ In particular these views provide base functionality for rendering Django forms
|
|||||||
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -627,7 +625,7 @@ class SetPasswordView(AjaxUpdateView):
|
|||||||
if valid:
|
if valid:
|
||||||
# Passwords must match
|
# Passwords must match
|
||||||
|
|
||||||
if not p1 == p2:
|
if p1 != p2:
|
||||||
error = _('Password fields must match')
|
error = _('Password fields must match')
|
||||||
form.add_error('enter_password', error)
|
form.add_error('enter_password', error)
|
||||||
form.add_error('confirm_password', error)
|
form.add_error('confirm_password', error)
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
JSON API for the Build app
|
JSON API for the Build app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from rest_framework import filters, generics
|
from rest_framework import filters, generics
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
Build database model definitions
|
Build database model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import decimal
|
import decimal
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -777,7 +775,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
if not output.is_building:
|
if not output.is_building:
|
||||||
raise ValidationError(_("Build output is already completed"))
|
raise ValidationError(_("Build output is already completed"))
|
||||||
|
|
||||||
if not output.build == self:
|
if output.build != self:
|
||||||
raise ValidationError(_("Build output does not match Build Order"))
|
raise ValidationError(_("Build output does not match Build Order"))
|
||||||
|
|
||||||
# Unallocate all build items against the output
|
# Unallocate all build items against the output
|
||||||
@ -1240,7 +1238,7 @@ class BuildItem(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Quantity must be 1 for serialized stock
|
# Quantity must be 1 for serialized stock
|
||||||
if self.stock_item.serialized and not self.quantity == 1:
|
if self.stock_item.serialized and self.quantity != 1:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'quantity': _('Quantity must be 1 for serialized stock')
|
'quantity': _('Quantity must be 1 for serialized stock')
|
||||||
})
|
})
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
JSON serializers for Build API
|
JSON serializers for Build API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with Build objects
|
Django views for interacting with Build objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Provides a JSON API for common components.
|
Provides a JSON API for common components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.http.response import HttpResponse
|
from django.http.response import HttpResponse
|
||||||
|
@ -199,7 +199,7 @@ class FileManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Excel import casts number-looking-items into floats, which is annoying
|
# Excel import casts number-looking-items into floats, which is annoying
|
||||||
if item == int(item) and not str(item) == str(int(item)):
|
if item == int(item) and str(item) != str(int(item)):
|
||||||
data[idx] = int(item)
|
data[idx] = int(item)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django forms for interacting with common objects
|
Django forms for interacting with common objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
@ -3,9 +3,6 @@ Common database model definitions.
|
|||||||
These models are 'generic' and do not fit a particular business logic object.
|
These models are 'generic' and do not fit a particular business logic object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import decimal
|
import decimal
|
||||||
import math
|
import math
|
||||||
@ -1802,10 +1799,8 @@ class WebhookEndpoint(models.Model):
|
|||||||
def process_webhook(self):
|
def process_webhook(self):
|
||||||
if self.token:
|
if self.token:
|
||||||
self.verify = VerificationMethod.TOKEN
|
self.verify = VerificationMethod.TOKEN
|
||||||
# TODO make a object-setting
|
|
||||||
if self.secret:
|
if self.secret:
|
||||||
self.verify = VerificationMethod.HMAC
|
self.verify = VerificationMethod.HMAC
|
||||||
# TODO make a object-setting
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_token(self, payload, headers, request):
|
def validate_token(self, payload, headers, request):
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
JSON serializers for common components
|
JSON serializers for common components
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
from InvenTree.helpers import get_objectreference
|
from InvenTree.helpers import get_objectreference
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
User-configurable settings for the common app
|
User-configurable settings for the common app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
@ -1,6 +1,3 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for the views associated with the 'common' app
|
Unit tests for the views associated with the 'common' app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
@ -133,7 +133,7 @@ class SettingsTest(TestCase):
|
|||||||
if description is None:
|
if description is None:
|
||||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
||||||
|
|
||||||
if not key == key.upper():
|
if key != key.upper():
|
||||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with common models
|
Django views for interacting with common models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Provides a JSON API for the Company app
|
Provides a JSON API for the Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Company app
|
Django Forms for interacting with Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Company database model definitions
|
Company database model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -494,7 +491,7 @@ class SupplierPart(models.Model):
|
|||||||
# Ensure that the linked manufacturer_part points to the same part!
|
# Ensure that the linked manufacturer_part points to the same part!
|
||||||
if self.manufacturer_part and self.part:
|
if self.manufacturer_part and self.part:
|
||||||
|
|
||||||
if not self.manufacturer_part.part == self.part:
|
if self.manufacturer_part.part != self.part:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
""" Unit tests for Company views (see views.py) """
|
""" Unit tests for Company views (see views.py) """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
@ -2,10 +2,6 @@
|
|||||||
Django views for interacting with Company app
|
Django views for interacting with Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
@ -162,7 +158,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
# Check for valid response code
|
# Check for valid response code
|
||||||
if not response.status_code == 200:
|
if response.status_code != 200:
|
||||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
|
|||||||
if os.path.exists(dst_file):
|
if os.path.exists(dst_file):
|
||||||
# File already exists - let's see if it is the "same"
|
# File already exists - let's see if it is the "same"
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Label printing models
|
Label printing models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
# Tests for labels
|
# Tests for labels
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
# Tests for labels
|
# Tests for labels
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -2,9 +2,6 @@
|
|||||||
JSON API for the Order app
|
JSON API for the Order app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.db.models import Q, F
|
from django.db.models import Q, F
|
||||||
|
|
||||||
@ -27,6 +24,8 @@ import order.serializers as serializers
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
|
|
||||||
class GeneralExtraLineList:
|
class GeneralExtraLineList:
|
||||||
"""
|
"""
|
||||||
@ -347,6 +346,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating PurchaseOrder metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to receive stock items against a purchase order.
|
API endpoint to receive stock items against a purchase order.
|
||||||
@ -916,6 +924,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating SalesOrder metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = models.SalesOrder.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to allocation stock items against a SalesOrder,
|
API endpoint to allocation stock items against a SalesOrder,
|
||||||
@ -1138,10 +1155,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Individual purchase order detail URLs
|
# Individual purchase order detail URLs
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
|
||||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
|
||||||
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
||||||
|
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||||
|
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
|
||||||
|
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||||
|
|
||||||
|
# PurchaseOrder detail API endpoint
|
||||||
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@ -1178,10 +1198,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Sales order detail view
|
# Sales order detail view
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
|
||||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
|
||||||
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||||
|
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||||
|
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||||
|
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
|
||||||
|
|
||||||
|
# SalesOrder detail endpoint
|
||||||
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Order objects
|
Django Forms for interacting with Order objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
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 part import models as PartModels
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
from plugin.models import MetadataMixin
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
@ -97,7 +99,7 @@ def get_next_so_number():
|
|||||||
return reference
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Order(ReferenceIndexingMixin):
|
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||||
""" Abstract model for an order.
|
""" Abstract model for an order.
|
||||||
|
|
||||||
Instances of this class:
|
Instances of this class:
|
||||||
@ -306,7 +308,7 @@ class PurchaseOrder(Order):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError({'quantity': _("Invalid quantity provided")})
|
raise ValidationError({'quantity': _("Invalid quantity provided")})
|
||||||
|
|
||||||
if not supplier_part.supplier == self.supplier:
|
if supplier_part.supplier != self.supplier:
|
||||||
raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
|
raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
|
||||||
|
|
||||||
if group:
|
if group:
|
||||||
@ -445,7 +447,7 @@ class PurchaseOrder(Order):
|
|||||||
if barcode is None:
|
if barcode is None:
|
||||||
barcode = ''
|
barcode = ''
|
||||||
|
|
||||||
if not self.status == PurchaseOrderStatus.PLACED:
|
if self.status != PurchaseOrderStatus.PLACED:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Lines can only be received against an order marked as 'PLACED'"
|
"Lines can only be received against an order marked as 'PLACED'"
|
||||||
)
|
)
|
||||||
@ -729,7 +731,7 @@ class SalesOrder(Order):
|
|||||||
Return True if this order can be cancelled
|
Return True if this order can be cancelled
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.status == SalesOrderStatus.PENDING:
|
if self.status != SalesOrderStatus.PENDING:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -1295,7 +1297,7 @@ class SalesOrderAllocation(models.Model):
|
|||||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.line.part == self.item.part:
|
if self.line.part != self.item.part:
|
||||||
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
||||||
except PartModels.Part.DoesNotExist:
|
except PartModels.Part.DoesNotExist:
|
||||||
errors['line'] = _('Cannot allocate stock to a line without a part')
|
errors['line'] = _('Cannot allocate stock to a line without a part')
|
||||||
@ -1310,7 +1312,7 @@ class SalesOrderAllocation(models.Model):
|
|||||||
if self.quantity <= 0:
|
if self.quantity <= 0:
|
||||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||||
|
|
||||||
if self.item.serial and not self.quantity == 1:
|
if self.item.serial and self.quantity != 1:
|
||||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||||
|
|
||||||
if self.line.order != self.shipment.order:
|
if self.line.order != self.shipment.order:
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
JSON serializers for the Order API
|
JSON serializers for the Order API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||||
|
|
||||||
|
def test_po_metadata(self):
|
||||||
|
url = reverse('api-po-metadata', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'yam': 'yum',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
|
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceiveTest(OrderTest):
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
||||||
|
|
||||||
|
def test_so_metadata(self):
|
||||||
|
url = reverse('api-so-metadata', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'xyz': 'abc',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.SalesOrder.objects.get(pk=1)
|
||||||
|
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateTest(OrderTest):
|
class SalesOrderAllocateTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
""" Unit tests for Order views (see views.py) """
|
""" Unit tests for Order views (see views.py) """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with Order app
|
Django views for interacting with Order app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.http.response import JsonResponse
|
from django.http.response import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Provides a JSON API for the Part app
|
Provides a JSON API for the Part app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
@ -44,6 +41,7 @@ from stock.models import StockItem, StockLocation
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
import order.models
|
import order.models
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
from . import serializers as part_serializers
|
from . import serializers as part_serializers
|
||||||
|
|
||||||
@ -203,6 +201,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating PartCategory metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(PartCategory, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = PartCategory.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterList(generics.ListAPIView):
|
class CategoryParameterList(generics.ListAPIView):
|
||||||
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||||
|
|
||||||
@ -587,6 +594,17 @@ class PartScheduling(generics.RetrieveAPIView):
|
|||||||
return Response(schedule)
|
return Response(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
class PartMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for viewing / updating Part metadata
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(Part, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class PartSerialNumberDetail(generics.RetrieveAPIView):
|
class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for returning extra serial number information about a particular part
|
API endpoint for returning extra serial number information about a particular part
|
||||||
@ -1912,7 +1930,15 @@ part_api_urls = [
|
|||||||
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
||||||
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||||
|
|
||||||
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
|
# Category detail endpoints
|
||||||
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
|
|
||||||
|
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
|
||||||
|
|
||||||
|
# PartCategory detail endpoint
|
||||||
|
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
path('', CategoryList.as_view(), name='api-part-category-list'),
|
path('', CategoryList.as_view(), name='api-part-category-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@ -1973,6 +1999,9 @@ part_api_urls = [
|
|||||||
# Endpoint for validating a BOM for the specific Part
|
# Endpoint for validating a BOM for the specific Part
|
||||||
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
|
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
|
||||||
|
|
||||||
|
# Part metadata
|
||||||
|
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
|
||||||
|
|
||||||
# Part detail endpoint
|
# Part detail endpoint
|
||||||
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
||||||
])),
|
])),
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Part objects
|
Django Forms for interacting with Part objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
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
|
Part database model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import decimal
|
import decimal
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -46,29 +44,29 @@ from common.models import InvenTreeSetting
|
|||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree import validators
|
from InvenTree import validators
|
||||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
|
||||||
from InvenTree.fields import InvenTreeURLField
|
|
||||||
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
|
||||||
|
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
|
||||||
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||||
|
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
|
||||||
|
import common.models
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from order import models as OrderModels
|
from order import models as OrderModels
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
|
import part.settings as part_settings
|
||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
|
|
||||||
import common.models
|
from plugin.models import MetadataMixin
|
||||||
|
|
||||||
import part.settings as part_settings
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
class PartCategory(InvenTreeTree):
|
class PartCategory(MetadataMixin, InvenTreeTree):
|
||||||
""" PartCategory provides hierarchical organization of Part objects.
|
""" PartCategory provides hierarchical organization of Part objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
@ -327,7 +325,7 @@ class PartManager(TreeManager):
|
|||||||
|
|
||||||
|
|
||||||
@cleanup.ignore
|
@cleanup.ignore
|
||||||
class Part(MPTTModel):
|
class Part(MetadataMixin, MPTTModel):
|
||||||
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||||
|
|
||||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||||
@ -444,7 +442,7 @@ class Part(MPTTModel):
|
|||||||
previous = Part.objects.get(pk=self.pk)
|
previous = Part.objects.get(pk=self.pk)
|
||||||
|
|
||||||
# Image has been changed
|
# Image has been changed
|
||||||
if previous.image is not None and not self.image == previous.image:
|
if previous.image is not None and self.image != previous.image:
|
||||||
|
|
||||||
# Are there any (other) parts which reference the image?
|
# Are there any (other) parts which reference the image?
|
||||||
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
|
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
|
||||||
@ -2895,7 +2893,7 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
|
|
||||||
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
||||||
if self.sub_part.trackable:
|
if self.sub_part.trackable:
|
||||||
if not self.quantity == int(self.quantity):
|
if self.quantity != int(self.quantity):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"quantity": _("Quantity must be integer value for trackable parts")
|
"quantity": _("Quantity must be integer value for trackable parts")
|
||||||
})
|
})
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
User-configurable settings for the Part app
|
User-configurable settings for the Part app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
|
@ -589,32 +589,15 @@
|
|||||||
// Get a list of the selected BOM items
|
// Get a list of the selected BOM items
|
||||||
var rows = $("#bom-table").bootstrapTable('getSelections');
|
var rows = $("#bom-table").bootstrapTable('getSelections');
|
||||||
|
|
||||||
// TODO - In the future, display (in the dialog) which items are going to be deleted
|
if (rows.length == 0) {
|
||||||
|
rows = $('#bom-table').bootstrapTable('getData');
|
||||||
|
}
|
||||||
|
|
||||||
showQuestionDialog(
|
deleteBomItems(rows, {
|
||||||
'{% trans "Delete selected BOM items?" %}',
|
success: function() {
|
||||||
'{% trans "All selected BOM items will be deleted" %}',
|
$('#bom-table').bootstrapTable('refresh');
|
||||||
{
|
|
||||||
accept: function() {
|
|
||||||
|
|
||||||
// Keep track of each DELETE request
|
|
||||||
var requests = [];
|
|
||||||
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
requests.push(
|
|
||||||
inventreeDelete(
|
|
||||||
`/api/bom/${row.pk}/`,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for *all* the requests to complete
|
|
||||||
$.when.apply($, requests).done(function() {
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#bom-upload').click(function() {
|
$('#bom-upload').click(function() {
|
||||||
|
@ -21,6 +21,85 @@ import build.models
|
|||||||
import order.models
|
import order.models
|
||||||
|
|
||||||
|
|
||||||
|
class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||||
|
"""Unit tests for the PartCategory API"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'bom',
|
||||||
|
'company',
|
||||||
|
'test_templates',
|
||||||
|
'manufacturer_part',
|
||||||
|
'supplier_part',
|
||||||
|
'order',
|
||||||
|
'stock',
|
||||||
|
]
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.change',
|
||||||
|
'part.add',
|
||||||
|
'part.delete',
|
||||||
|
'part_category.change',
|
||||||
|
'part_category.add',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_category_list(self):
|
||||||
|
|
||||||
|
# List all part categories
|
||||||
|
url = reverse('api-part-category-list')
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 8)
|
||||||
|
|
||||||
|
# Filter by parent, depth=1
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'parent': 1,
|
||||||
|
'cascade': False,
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 3)
|
||||||
|
|
||||||
|
# Filter by parent, cascading
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'parent': 1,
|
||||||
|
'cascade': True,
|
||||||
|
},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
|
def test_category_metadata(self):
|
||||||
|
"""Test metadata endpoint for the PartCategory"""
|
||||||
|
|
||||||
|
cat = PartCategory.objects.get(pk=1)
|
||||||
|
|
||||||
|
cat.metadata = {
|
||||||
|
'foo': 'bar',
|
||||||
|
'water': 'melon',
|
||||||
|
'abc': 'xyz',
|
||||||
|
}
|
||||||
|
|
||||||
|
cat.set_metadata('abc', 'ABC')
|
||||||
|
|
||||||
|
response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200)
|
||||||
|
|
||||||
|
metadata = response.data['metadata']
|
||||||
|
|
||||||
|
self.assertEqual(metadata['foo'], 'bar')
|
||||||
|
self.assertEqual(metadata['water'], 'melon')
|
||||||
|
self.assertEqual(metadata['abc'], 'ABC')
|
||||||
|
|
||||||
|
|
||||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
Tests for the various OPTIONS endpoints in the /part/ API
|
Tests for the various OPTIONS endpoints in the /part/ API
|
||||||
@ -1021,6 +1100,59 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['in_stock'], 9000)
|
self.assertEqual(data['in_stock'], 9000)
|
||||||
self.assertEqual(data['unallocated_stock'], 9000)
|
self.assertEqual(data['unallocated_stock'], 9000)
|
||||||
|
|
||||||
|
def test_part_metadata(self):
|
||||||
|
"""
|
||||||
|
Tests for the part metadata endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-metadata', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Metadata is initially null
|
||||||
|
self.assertIsNone(part.metadata)
|
||||||
|
|
||||||
|
part.metadata = {'foo': 'bar'}
|
||||||
|
part.save()
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(response.data['metadata']['foo'], 'bar')
|
||||||
|
|
||||||
|
# Add more data via the API
|
||||||
|
# Using the 'patch' method causes the new data to be merged in
|
||||||
|
self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'hello': 'world',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
part.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(part.metadata['foo'], 'bar')
|
||||||
|
self.assertEqual(part.metadata['hello'], 'world')
|
||||||
|
|
||||||
|
# Now, issue a PUT request (existing data will be replacted)
|
||||||
|
self.put(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'x': 'y'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
part.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertFalse('foo' in part.metadata)
|
||||||
|
self.assertFalse('hello' in part.metadata)
|
||||||
|
self.assertEqual(part.metadata['x'], 'y')
|
||||||
|
|
||||||
|
|
||||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
# Tests for Part Parameters
|
# Tests for Part Parameters
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase, TransactionTestCase
|
from django.test import TestCase, TransactionTestCase
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
|
|
||||||
|
@ -199,7 +199,7 @@ class PartTest(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
part_2.validate_unique()
|
part_2.validate_unique()
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_attributes(self):
|
||||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||||
|
|
||||||
@ -245,6 +245,24 @@ class PartTest(TestCase):
|
|||||||
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
|
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
|
||||||
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||||
|
|
||||||
|
def test_metadata(self):
|
||||||
|
"""Unit tests for the Part metadata field"""
|
||||||
|
|
||||||
|
p = Part.objects.get(pk=1)
|
||||||
|
self.assertIsNone(p.metadata)
|
||||||
|
|
||||||
|
self.assertIsNone(p.get_metadata('test'))
|
||||||
|
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||||
|
|
||||||
|
# Test update via the set_metadata() method
|
||||||
|
p.set_metadata('test', 3)
|
||||||
|
self.assertEqual(p.get_metadata('test'), 3)
|
||||||
|
|
||||||
|
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||||
|
p.set_metadata(k, k)
|
||||||
|
|
||||||
|
self.assertEqual(len(p.metadata.keys()), 4)
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateTest(TestCase):
|
class TestTemplateTest(TestCase):
|
||||||
|
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with Part app
|
Django views for interacting with Part app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@ -628,7 +625,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
|||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
# Check for valid response code
|
# Check for valid response code
|
||||||
if not response.status_code == 200:
|
if response.status_code != 200:
|
||||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -2,15 +2,10 @@
|
|||||||
JSON API for the plugin app
|
JSON API for the plugin app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from rest_framework import generics
|
from rest_framework import filters, generics, permissions, status
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework import permissions
|
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
@ -19,6 +14,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from common.api import GlobalSettingsPermissions
|
from common.api import GlobalSettingsPermissions
|
||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.action.api import ActionPluginView
|
from plugin.base.action.api import ActionPluginView
|
||||||
|
from plugin.base.locate.api import LocatePluginView
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
import plugin.serializers as PluginSerializers
|
import plugin.serializers as PluginSerializers
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
@ -38,6 +34,35 @@ class PluginList(generics.ListAPIView):
|
|||||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||||
queryset = PluginConfig.objects.all()
|
queryset = PluginConfig.objects.all()
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
# Filter plugins which support a given mixin
|
||||||
|
mixin = params.get('mixin', None)
|
||||||
|
|
||||||
|
if mixin:
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
for result in queryset:
|
||||||
|
if mixin in result.mixins().keys():
|
||||||
|
matches.append(result.pk)
|
||||||
|
|
||||||
|
queryset = queryset.filter(pk__in=matches)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
filters.SearchFilter,
|
||||||
|
filters.OrderingFilter,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'active',
|
||||||
|
]
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'key',
|
'key',
|
||||||
'name',
|
'name',
|
||||||
@ -163,6 +188,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
|||||||
plugin_api_urls = [
|
plugin_api_urls = [
|
||||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||||
|
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
|
||||||
]
|
]
|
||||||
|
|
||||||
general_plugin_api_urls = [
|
general_plugin_api_urls = [
|
||||||
|
@ -31,12 +31,8 @@ class ActionPluginView(APIView):
|
|||||||
action_plugins = registry.with_mixin('action')
|
action_plugins = registry.with_mixin('action')
|
||||||
for plugin in action_plugins:
|
for plugin in action_plugins:
|
||||||
if plugin.action_name() == action:
|
if plugin.action_name() == action:
|
||||||
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released
|
plugin.perform_action(request.user, data=data)
|
||||||
plugin.init(request.user, data=data)
|
return Response(plugin.get_response(request.user, data=data))
|
||||||
|
|
||||||
plugin.perform_action()
|
|
||||||
|
|
||||||
return Response(plugin.get_response())
|
|
||||||
|
|
||||||
# If we got to here, no matching action was found
|
# If we got to here, no matching action was found
|
||||||
return Response({
|
return Response({
|
||||||
|
@ -15,10 +15,9 @@ class ActionMixin:
|
|||||||
"""
|
"""
|
||||||
MIXIN_NAME = 'Actions'
|
MIXIN_NAME = 'Actions'
|
||||||
|
|
||||||
def __init__(self, user=None, data=None):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('action', True, __class__)
|
self.add_mixin('action', True, __class__)
|
||||||
self.init(user, data)
|
|
||||||
|
|
||||||
def action_name(self):
|
def action_name(self):
|
||||||
"""
|
"""
|
||||||
@ -31,19 +30,12 @@ class ActionMixin:
|
|||||||
return self.ACTION_NAME
|
return self.ACTION_NAME
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def init(self, user, data=None):
|
def perform_action(self, user=None, data=None):
|
||||||
"""
|
|
||||||
An action plugin takes a user reference, and an optional dataset (dict)
|
|
||||||
"""
|
|
||||||
self.user = user
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def perform_action(self):
|
|
||||||
"""
|
"""
|
||||||
Override this method to perform the action!
|
Override this method to perform the action!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self, user=None, data=None):
|
||||||
"""
|
"""
|
||||||
Result of the action?
|
Result of the action?
|
||||||
"""
|
"""
|
||||||
@ -51,19 +43,19 @@ class ActionMixin:
|
|||||||
# Re-implement this for cutsom actions
|
# Re-implement this for cutsom actions
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self, user=None, data=None):
|
||||||
"""
|
"""
|
||||||
Extra info? Can be a string / dict / etc
|
Extra info? Can be a string / dict / etc
|
||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_response(self):
|
def get_response(self, user=None, data=None):
|
||||||
"""
|
"""
|
||||||
Return a response. Default implementation is a simple response
|
Return a response. Default implementation is a simple response
|
||||||
which can be overridden.
|
which can be overridden.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"action": self.action_name(),
|
"action": self.action_name(),
|
||||||
"result": self.get_result(),
|
"result": self.get_result(user, data),
|
||||||
"info": self.get_info(),
|
"info": self.get_info(user, data),
|
||||||
}
|
}
|
||||||
|
@ -14,27 +14,27 @@ class ActionMixinTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
class SimplePlugin(ActionMixin, InvenTreePlugin):
|
class SimplePlugin(ActionMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.plugin = SimplePlugin('user')
|
self.plugin = SimplePlugin()
|
||||||
|
|
||||||
class TestActionPlugin(ActionMixin, InvenTreePlugin):
|
class TestActionPlugin(ActionMixin, InvenTreePlugin):
|
||||||
"""a action plugin"""
|
"""a action plugin"""
|
||||||
ACTION_NAME = 'abc123'
|
ACTION_NAME = 'abc123'
|
||||||
|
|
||||||
def perform_action(self):
|
def perform_action(self, user=None, data=None):
|
||||||
return ActionMixinTests.ACTION_RETURN + 'action'
|
return ActionMixinTests.ACTION_RETURN + 'action'
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self, user=None, data=None):
|
||||||
return ActionMixinTests.ACTION_RETURN + 'result'
|
return ActionMixinTests.ACTION_RETURN + 'result'
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self, user=None, data=None):
|
||||||
return ActionMixinTests.ACTION_RETURN + 'info'
|
return ActionMixinTests.ACTION_RETURN + 'info'
|
||||||
|
|
||||||
self.action_plugin = TestActionPlugin('user')
|
self.action_plugin = TestActionPlugin()
|
||||||
|
|
||||||
class NameActionPlugin(ActionMixin, InvenTreePlugin):
|
class NameActionPlugin(ActionMixin, InvenTreePlugin):
|
||||||
NAME = 'Aplugin'
|
NAME = 'Aplugin'
|
||||||
|
|
||||||
self.action_name = NameActionPlugin('user')
|
self.action_name = NameActionPlugin()
|
||||||
|
|
||||||
def test_action_name(self):
|
def test_action_name(self):
|
||||||
"""check the name definition possibilities"""
|
"""check the name definition possibilities"""
|
||||||
|
@ -63,7 +63,6 @@ class BarcodeScan(APIView):
|
|||||||
plugin = None
|
plugin = None
|
||||||
|
|
||||||
for current_plugin in plugins:
|
for current_plugin in plugins:
|
||||||
# TODO @matmair make simpler after InvenTree 0.7.0 release
|
|
||||||
current_plugin.init(barcode_data)
|
current_plugin.init(barcode_data)
|
||||||
|
|
||||||
if current_plugin.validate():
|
if current_plugin.validate():
|
||||||
@ -168,7 +167,6 @@ class BarcodeAssign(APIView):
|
|||||||
plugin = None
|
plugin = None
|
||||||
|
|
||||||
for current_plugin in plugins:
|
for current_plugin in plugins:
|
||||||
# TODO @matmair make simpler after InvenTree 0.7.0 release
|
|
||||||
current_plugin.init(barcode_data)
|
current_plugin.init(barcode_data)
|
||||||
|
|
||||||
if current_plugin.validate():
|
if current_plugin.validate():
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Functions for triggering and responding to server side events
|
Functions for triggering and responding to server side events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
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"
|
NAME = "SimpleActionPlugin"
|
||||||
ACTION_NAME = "simple"
|
ACTION_NAME = "simple"
|
||||||
|
|
||||||
def perform_action(self):
|
def perform_action(self, user=None, data=None):
|
||||||
print("Action plugin in action!")
|
print("Action plugin in action!")
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self, user, data=None):
|
||||||
return {
|
return {
|
||||||
"user": self.user.username,
|
"user": user.username,
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self, user=None, data=None):
|
||||||
return True
|
return True
|
||||||
|
@ -15,7 +15,7 @@ class SimpleActionPluginTests(TestCase):
|
|||||||
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
self.plugin = SimpleActionPlugin(user=self.test_user)
|
self.plugin = SimpleActionPlugin()
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
"""check plugn names """
|
"""check plugn names """
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
Import helper for events
|
Import helper for events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin.base.event.events import trigger_event
|
from plugin.base.event.events import process_event, register_event, trigger_event
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'process_event',
|
||||||
|
'register_event',
|
||||||
'trigger_event',
|
'trigger_event',
|
||||||
]
|
]
|
||||||
|
@ -10,6 +10,7 @@ from ..base.action.mixins import ActionMixin
|
|||||||
from ..base.barcodes.mixins import BarcodeMixin
|
from ..base.barcodes.mixins import BarcodeMixin
|
||||||
from ..base.event.mixins import EventMixin
|
from ..base.event.mixins import EventMixin
|
||||||
from ..base.label.mixins import LabelPrintingMixin
|
from ..base.label.mixins import LabelPrintingMixin
|
||||||
|
from ..base.locate.mixins import LocateMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'APICallMixin',
|
'APICallMixin',
|
||||||
@ -23,6 +24,7 @@ __all__ = [
|
|||||||
'PanelMixin',
|
'PanelMixin',
|
||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
|
'LocateMixin',
|
||||||
'SingleNotificationMethod',
|
'SingleNotificationMethod',
|
||||||
'BulkNotificationMethod',
|
'BulkNotificationMethod',
|
||||||
]
|
]
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
Plugin model definitions
|
Plugin model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -16,6 +14,65 @@ import common.models
|
|||||||
from plugin import InvenTreePlugin, registry
|
from plugin import InvenTreePlugin, registry
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataMixin(models.Model):
|
||||||
|
"""
|
||||||
|
Model mixin class which adds a JSON metadata field to a model,
|
||||||
|
for use by any (and all) plugins.
|
||||||
|
|
||||||
|
The intent of this mixin is to provide a metadata field on a model instance,
|
||||||
|
for plugins to read / modify as required, to store any extra information.
|
||||||
|
|
||||||
|
The assumptions for models implementing this mixin are:
|
||||||
|
|
||||||
|
- The internal InvenTree business logic will make no use of this field
|
||||||
|
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
metadata = models.JSONField(
|
||||||
|
blank=True, null=True,
|
||||||
|
verbose_name=_('Plugin Metadata'),
|
||||||
|
help_text=_('JSON metadata field, for use by external plugins'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_metadata(self, key: str, backup_value=None):
|
||||||
|
"""
|
||||||
|
Finds metadata for this model instance, using the provided key for lookup
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Python dict object containing requested metadata. If no matching metadata is found, returns None
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.metadata is None:
|
||||||
|
return backup_value
|
||||||
|
|
||||||
|
return self.metadata.get(key, backup_value)
|
||||||
|
|
||||||
|
def set_metadata(self, key: str, data, commit=True):
|
||||||
|
"""
|
||||||
|
Save the provided metadata under the provided key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: String key for saving metadata
|
||||||
|
data: Data object to save - must be able to be rendered as a JSON string
|
||||||
|
overwrite: If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.metadata is None:
|
||||||
|
# Handle a null field value
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
|
self.metadata[key] = data
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class PluginConfig(models.Model):
|
class PluginConfig(models.Model):
|
||||||
"""
|
"""
|
||||||
A PluginConfig object holds settings for plugins.
|
A PluginConfig object holds settings for plugins.
|
||||||
|
@ -133,7 +133,6 @@ class PluginsRegistry:
|
|||||||
if retry_counter <= 0: # pragma: no cover
|
if retry_counter <= 0: # pragma: no cover
|
||||||
if settings.PLUGIN_TESTING:
|
if settings.PLUGIN_TESTING:
|
||||||
print('[PLUGIN] Max retries, breaking loading')
|
print('[PLUGIN] Max retries, breaking loading')
|
||||||
# TODO error for server status
|
|
||||||
break
|
break
|
||||||
if settings.PLUGIN_TESTING:
|
if settings.PLUGIN_TESTING:
|
||||||
print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
|
print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
|
||||||
@ -301,7 +300,6 @@ class PluginsRegistry:
|
|||||||
# Errors are bad so disable the plugin in the database
|
# Errors are bad so disable the plugin in the database
|
||||||
if not settings.PLUGIN_TESTING: # pragma: no cover
|
if not settings.PLUGIN_TESTING: # pragma: no cover
|
||||||
plugin_db_setting.active = False
|
plugin_db_setting.active = False
|
||||||
# TODO save the error to the plugin
|
|
||||||
plugin_db_setting.save(no_reload=True)
|
plugin_db_setting.save(no_reload=True)
|
||||||
|
|
||||||
# Add to inactive plugins so it shows up in the ui
|
# Add to inactive plugins so it shows up in the ui
|
||||||
@ -310,8 +308,6 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# Initialize package
|
# Initialize package
|
||||||
# now we can be sure that an admin has activated the plugin
|
# now we can be sure that an admin has activated the plugin
|
||||||
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
|
||||||
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
|
||||||
logger.info(f'Loading plugin {plug_name}')
|
logger.info(f'Loading plugin {plug_name}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
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
|
JSON serializers for plugin app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@ -19,6 +16,34 @@ from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
|
|||||||
from common.serializers import GenericReferencedSettingSerializer
|
from common.serializers import GenericReferencedSettingSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer class for model metadata API access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata = serializers.JSONField(required=True)
|
||||||
|
|
||||||
|
def __init__(self, model_type, *args, **kwargs):
|
||||||
|
|
||||||
|
self.Meta.model = model_type
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'metadata',
|
||||||
|
]
|
||||||
|
|
||||||
|
def update(self, instance, data):
|
||||||
|
|
||||||
|
if self.partial:
|
||||||
|
# Default behaviour is to "merge" new data in
|
||||||
|
metadata = instance.metadata.copy() if instance.metadata else {}
|
||||||
|
metadata.update(data['metadata'])
|
||||||
|
data['metadata'] = metadata
|
||||||
|
|
||||||
|
return super().update(instance, data)
|
||||||
|
|
||||||
|
|
||||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a PluginConfig:
|
Serializer for a PluginConfig:
|
||||||
|
@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
|
|||||||
return plugin.mixin_enabled(key)
|
return plugin.mixin_enabled(key)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def mixin_available(mixin, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns True if there is at least one active plugin which supports the provided mixin
|
||||||
|
"""
|
||||||
|
return len(registry.with_mixin(mixin)) > 0
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def navigation_enabled(*args, **kwargs):
|
def navigation_enabled(*args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -87,7 +87,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
AUTHOR = 'AA BB'
|
AUTHOR = 'AA BB'
|
||||||
DESCRIPTION = 'A description'
|
DESCRIPTION = 'A description'
|
||||||
VERSION = '1.2.3a'
|
VERSION = '1.2.3a'
|
||||||
WEBSITE = 'http://aa.bb/cc'
|
WEBSITE = 'https://aa.bb/cc'
|
||||||
LICENSE = 'MIT'
|
LICENSE = 'MIT'
|
||||||
|
|
||||||
self.plugin_name = NameInvenTreePlugin()
|
self.plugin_name = NameInvenTreePlugin()
|
||||||
@ -147,7 +147,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
# website
|
# website
|
||||||
self.assertEqual(self.plugin.website, None)
|
self.assertEqual(self.plugin.website, None)
|
||||||
self.assertEqual(self.plugin_simple.website, None)
|
self.assertEqual(self.plugin_simple.website, None)
|
||||||
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
|
self.assertEqual(self.plugin_name.website, 'https://aa.bb/cc')
|
||||||
|
|
||||||
# license
|
# license
|
||||||
self.assertEqual(self.plugin.license, None)
|
self.assertEqual(self.plugin.license, None)
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Report template model definitions
|
Report template model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
@ -389,7 +386,7 @@ class BuildReport(ReportTemplateBase):
|
|||||||
|
|
||||||
my_build = self.object_to_print
|
my_build = self.object_to_print
|
||||||
|
|
||||||
if not type(my_build) == build.models.Build:
|
if type(my_build) != build.models.Build:
|
||||||
raise TypeError('Provided model is not a Build object')
|
raise TypeError('Provided model is not a Build object')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
JSON API for the Stock app
|
JSON API for the Stock app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@ -43,6 +40,8 @@ from order.serializers import PurchaseOrderSerializer
|
|||||||
from part.models import BomItem, Part, PartCategory
|
from part.models import BomItem, Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
from stock.admin import StockItemResource
|
from stock.admin import StockItemResource
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
from stock.models import StockItemTracking
|
from stock.models import StockItemTracking
|
||||||
@ -92,6 +91,15 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class StockMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating StockItem metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(StockItem, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = StockItem.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class StockItemContextMixin:
|
class StockItemContextMixin:
|
||||||
""" Mixin class for adding StockItem object to serializer context """
|
""" Mixin class for adding StockItem object to serializer context """
|
||||||
|
|
||||||
@ -1368,6 +1376,15 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LocationMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating StockLocation metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(StockLocation, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = StockLocation.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of StockLocation object
|
""" API endpoint for detail view of StockLocation object
|
||||||
|
|
||||||
@ -1385,7 +1402,14 @@ stock_api_urls = [
|
|||||||
|
|
||||||
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
|
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
|
||||||
|
|
||||||
re_path(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
|
# Stock location detail endpoints
|
||||||
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
|
|
||||||
|
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
|
||||||
|
|
||||||
|
re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@ -1417,8 +1441,9 @@ stock_api_urls = [
|
|||||||
|
|
||||||
# Detail views for a single stock item
|
# Detail views for a single stock item
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
|
||||||
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||||
|
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
|
||||||
|
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
||||||
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
|
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
|
||||||
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
||||||
])),
|
])),
|
||||||
|
@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Stock app
|
Django Forms for interacting with Stock app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
|
|
||||||
from .models import StockItem, StockItemTracking
|
from .models import StockItem, StockItemTracking
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user