mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
update the remaining docstrings
This commit is contained in:
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Main JSON interface views."""
|
||||||
Main JSON interface views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@ -16,7 +14,8 @@ from .views import AjaxView
|
|||||||
|
|
||||||
|
|
||||||
class InfoView(AjaxView):
|
class InfoView(AjaxView):
|
||||||
""" Simple JSON endpoint for InvenTree information.
|
"""Simple JSON endpoint for InvenTree information.
|
||||||
|
|
||||||
Use to confirm that the server is running, etc.
|
Use to confirm that the server is running, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -37,9 +36,7 @@ class InfoView(AjaxView):
|
|||||||
|
|
||||||
|
|
||||||
class NotFoundView(AjaxView):
|
class NotFoundView(AjaxView):
|
||||||
"""
|
"""Simple JSON view when accessing an invalid API view."""
|
||||||
Simple JSON view when accessing an invalid API view.
|
|
||||||
"""
|
|
||||||
|
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
@ -54,8 +51,7 @@ class NotFoundView(AjaxView):
|
|||||||
|
|
||||||
|
|
||||||
class APIDownloadMixin:
|
class APIDownloadMixin:
|
||||||
"""
|
"""Mixin for enabling a LIST endpoint to be downloaded a file.
|
||||||
Mixin for enabling a LIST endpoint to be downloaded a file.
|
|
||||||
|
|
||||||
To download the data, add the ?export=<fmt> to the query string.
|
To download the data, add the ?export=<fmt> to the query string.
|
||||||
|
|
||||||
@ -92,10 +88,7 @@ class APIDownloadMixin:
|
|||||||
|
|
||||||
|
|
||||||
class AttachmentMixin:
|
class AttachmentMixin:
|
||||||
"""
|
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
|
||||||
Mixin for creating attachment objects,
|
|
||||||
and ensuring the user information is saved correctly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
@ -106,8 +99,7 @@ class AttachmentMixin:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
""" Save the user information when a file is uploaded """
|
"""Save the user information when a file is uploaded."""
|
||||||
|
|
||||||
attachment = serializer.save()
|
attachment = serializer.save()
|
||||||
attachment.user = self.request.user
|
attachment.user = self.request.user
|
||||||
attachment.save()
|
attachment.save()
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Helper functions for performing API unit tests."""
|
||||||
Helper functions for performing API unit tests
|
|
||||||
"""
|
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
@ -62,10 +60,7 @@ class UserMixin:
|
|||||||
self.client.login(username=self.username, password=self.password)
|
self.client.login(username=self.username, password=self.password)
|
||||||
|
|
||||||
def assignRole(self, role=None, assign_all: bool = False):
|
def assignRole(self, role=None, assign_all: bool = False):
|
||||||
"""
|
"""Set the user roles for the registered user."""
|
||||||
Set the user roles for the registered user
|
|
||||||
"""
|
|
||||||
|
|
||||||
# role is of the format 'rule.permission' e.g. 'part.add'
|
# role is of the format 'rule.permission' e.g. 'part.add'
|
||||||
|
|
||||||
if not assign_all and role:
|
if not assign_all and role:
|
||||||
@ -89,16 +84,13 @@ class UserMixin:
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeAPITestCase(UserMixin, APITestCase):
|
class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||||
"""
|
"""Base class for running InvenTree API tests."""
|
||||||
Base class for running InvenTree API tests
|
|
||||||
"""
|
|
||||||
|
|
||||||
def getActions(self, url):
|
def getActions(self, url):
|
||||||
"""
|
"""Return a dict of the 'actions' available at a given endpoint.
|
||||||
Return a dict of the 'actions' available at a given endpoint.
|
|
||||||
Makes use of the HTTP 'OPTIONS' method to request this.
|
Makes use of the HTTP 'OPTIONS' method to request this.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.options(url)
|
response = self.client.options(url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -110,10 +102,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return actions
|
return actions
|
||||||
|
|
||||||
def get(self, url, data={}, expected_code=200):
|
def get(self, url, data={}, expected_code=200):
|
||||||
"""
|
"""Issue a GET request."""
|
||||||
Issue a GET request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format='json')
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -122,10 +111,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def post(self, url, data, expected_code=None, format='json'):
|
def post(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""Issue a POST request."""
|
||||||
Issue a POST request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.post(url, data=data, format=format)
|
response = self.client.post(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -134,10 +120,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def delete(self, url, expected_code=None):
|
def delete(self, url, expected_code=None):
|
||||||
"""
|
"""Issue a DELETE request."""
|
||||||
Issue a DELETE request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.delete(url)
|
response = self.client.delete(url)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -146,10 +129,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def patch(self, url, data, expected_code=None, format='json'):
|
def patch(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""Issue a PATCH request."""
|
||||||
Issue a PATCH request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.patch(url, data=data, format=format)
|
response = self.client.patch(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -158,10 +138,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def put(self, url, data, expected_code=None, format='json'):
|
def put(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""Issue a PUT request."""
|
||||||
Issue a PUT request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.put(url, data=data, format=format)
|
response = self.client.put(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -170,10 +147,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def options(self, url, expected_code=None):
|
def options(self, url, expected_code=None):
|
||||||
"""
|
"""Issue an OPTIONS request."""
|
||||||
Issue an OPTIONS request
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.options(url, format='json')
|
response = self.client.options(url, format='json')
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -182,10 +156,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def download_file(self, url, data, expected_code=None, expected_fn=None, decode=True):
|
def download_file(self, url, data, expected_code=None, expected_fn=None, decode=True):
|
||||||
"""
|
"""Download a file from the server, and return an in-memory file."""
|
||||||
Download a file from the server, and return an in-memory file
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.get(url, data=data, format='json')
|
response = self.client.get(url, data=data, format='json')
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
@ -221,10 +192,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
return fo
|
return fo
|
||||||
|
|
||||||
def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
|
def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
|
||||||
"""
|
"""Helper function to process and validate a downloaded csv file."""
|
||||||
Helper function to process and validate a downloaded csv file
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Check that the correct object type has been passed
|
# Check that the correct object type has been passed
|
||||||
self.assertTrue(isinstance(fo, io.StringIO))
|
self.assertTrue(isinstance(fo, io.StringIO))
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""InvenTree API version information."""
|
||||||
InvenTree API version information
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
|
@ -37,10 +37,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
self.add_user_on_startup()
|
self.add_user_on_startup()
|
||||||
|
|
||||||
def remove_obsolete_tasks(self):
|
def remove_obsolete_tasks(self):
|
||||||
"""
|
"""Delete any obsolete scheduled tasks in the database."""
|
||||||
Delete any obsolete scheduled tasks in the database
|
|
||||||
"""
|
|
||||||
|
|
||||||
obsolete = [
|
obsolete = [
|
||||||
'InvenTree.tasks.delete_expired_sessions',
|
'InvenTree.tasks.delete_expired_sessions',
|
||||||
'stock.tasks.delete_old_stock_items',
|
'stock.tasks.delete_old_stock_items',
|
||||||
@ -101,13 +98,11 @@ class InvenTreeConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_exchange_rates(self): # pragma: no cover
|
def update_exchange_rates(self): # pragma: no cover
|
||||||
"""
|
"""Update exchange rates each time the server is started, *if*:
|
||||||
Update exchange rates each time the server is started, *if*:
|
|
||||||
|
|
||||||
a) Have not been updated recently (one day or less)
|
a) Have not been updated recently (one day or less)
|
||||||
b) The base exchange rate has been altered
|
b) The base exchange rate has been altered
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||||
|
|
||||||
@ -150,7 +145,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
logger.error(f"Error updating exchange rates: {e}")
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|
||||||
def add_user_on_startup(self):
|
def add_user_on_startup(self):
|
||||||
"""Add a user on startup"""
|
"""Add a user on startup."""
|
||||||
# stop if checks were already created
|
# stop if checks were already created
|
||||||
if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
|
if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
|
||||||
return
|
return
|
||||||
@ -202,9 +197,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
settings.USER_ADDED = True
|
settings.USER_ADDED = True
|
||||||
|
|
||||||
def collect_notification_methods(self):
|
def collect_notification_methods(self):
|
||||||
"""
|
"""Collect all notification methods."""
|
||||||
Collect all notification methods
|
|
||||||
"""
|
|
||||||
from common.notifications import storage
|
from common.notifications import storage
|
||||||
|
|
||||||
storage.collect()
|
storage.collect()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""
|
"""Pull rendered copies of the templated.
|
||||||
Pull rendered copies of the templated
|
|
||||||
only used for testing the js files! - This file is omited from coverage
|
Only used for testing the js files! - This file is omited from coverage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os # pragma: no cover
|
import os # pragma: no cover
|
||||||
@ -10,8 +10,7 @@ from InvenTree.helpers import InvenTreeTestCase # pragma: no cover
|
|||||||
|
|
||||||
|
|
||||||
class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
||||||
"""
|
"""A unit test to "render" javascript files.
|
||||||
A unit test to "render" javascript files.
|
|
||||||
|
|
||||||
The server renders templated javascript files,
|
The server renders templated javascript files,
|
||||||
we need the fully-rendered files for linting and static tests.
|
we need the fully-rendered files for linting and static tests.
|
||||||
@ -73,10 +72,7 @@ class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
def test_render_files(self):
|
def test_render_files(self):
|
||||||
"""
|
"""Look for all javascript files."""
|
||||||
Look for all javascript files
|
|
||||||
"""
|
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
|
|
||||||
print("Rendering javascript files...")
|
print("Rendering javascript files...")
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Helper functions for loading InvenTree configuration options."""
|
||||||
Helper functions for loading InvenTree configuration options
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -10,17 +8,15 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
def get_base_dir():
|
def get_base_dir():
|
||||||
""" Returns the base (top-level) InvenTree directory """
|
"""Returns the base (top-level) InvenTree directory."""
|
||||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
def get_config_file():
|
def get_config_file():
|
||||||
"""
|
"""Returns the path of the InvenTree configuration file.
|
||||||
Returns the path of the InvenTree configuration file.
|
|
||||||
|
|
||||||
Note: It will be created it if does not already exist!
|
Note: It will be created it if does not already exist!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
base_dir = get_base_dir()
|
base_dir = get_base_dir()
|
||||||
|
|
||||||
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
|
||||||
@ -43,8 +39,7 @@ def get_config_file():
|
|||||||
|
|
||||||
|
|
||||||
def get_plugin_file():
|
def get_plugin_file():
|
||||||
"""
|
"""Returns the path of the InvenTree plugins specification file.
|
||||||
Returns the path of the InvenTree plugins specification file.
|
|
||||||
|
|
||||||
Note: It will be created if it does not already exist!
|
Note: It will be created if it does not already exist!
|
||||||
"""
|
"""
|
||||||
@ -70,14 +65,12 @@ def get_plugin_file():
|
|||||||
|
|
||||||
|
|
||||||
def get_setting(environment_var, backup_val, default_value=None):
|
def get_setting(environment_var, backup_val, default_value=None):
|
||||||
"""
|
"""Helper function for retrieving a configuration setting value.
|
||||||
Helper function for retrieving a configuration setting value
|
|
||||||
|
|
||||||
- First preference is to look for the environment variable
|
- First preference is to look for the environment variable
|
||||||
- Second preference is to look for the value of the settings file
|
- Second preference is to look for the value of the settings file
|
||||||
- Third preference is the default value
|
- Third preference is the default value
|
||||||
"""
|
"""
|
||||||
|
|
||||||
val = os.getenv(environment_var)
|
val = os.getenv(environment_var)
|
||||||
|
|
||||||
if val is not None:
|
if val is not None:
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
"""Provides extra global data to all templates."""
|
||||||
Provides extra global data to all templates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import InvenTree.status
|
import InvenTree.status
|
||||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||||
@ -12,13 +10,11 @@ from users.models import RuleSet
|
|||||||
|
|
||||||
|
|
||||||
def health_status(request):
|
def health_status(request):
|
||||||
"""
|
"""Provide system health status information to the global context.
|
||||||
Provide system health status information to the global context.
|
|
||||||
|
|
||||||
- Not required for AJAX requests
|
- Not required for AJAX requests
|
||||||
- Do not provide if it is already provided to the context
|
- Do not provide if it is already provided to the context
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if request.path.endswith('.js'):
|
if request.path.endswith('.js'):
|
||||||
# Do not provide to script requests
|
# Do not provide to script requests
|
||||||
return {} # pragma: no cover
|
return {} # pragma: no cover
|
||||||
@ -53,10 +49,7 @@ def health_status(request):
|
|||||||
|
|
||||||
|
|
||||||
def status_codes(request):
|
def status_codes(request):
|
||||||
"""
|
"""Provide status code enumerations."""
|
||||||
Provide status code enumerations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if hasattr(request, '_inventree_status_codes'):
|
if hasattr(request, '_inventree_status_codes'):
|
||||||
# Do not duplicate efforts
|
# Do not duplicate efforts
|
||||||
return {}
|
return {}
|
||||||
@ -74,8 +67,7 @@ def status_codes(request):
|
|||||||
|
|
||||||
|
|
||||||
def user_roles(request):
|
def user_roles(request):
|
||||||
"""
|
"""Return a map of the current roles assigned to the user.
|
||||||
Return a map of the current roles assigned to the user.
|
|
||||||
|
|
||||||
Roles are denoted by their simple names, and then the permission type.
|
Roles are denoted by their simple names, and then the permission type.
|
||||||
|
|
||||||
@ -86,7 +78,6 @@ def user_roles(request):
|
|||||||
|
|
||||||
Each value will return a boolean True / False
|
Each value will return a boolean True / False
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
roles = {
|
roles = {
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Custom exception handling for the DRF API."""
|
||||||
Custom exception handling for the DRF API
|
|
||||||
"""
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
@ -21,13 +19,11 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
|
|
||||||
def exception_handler(exc, context):
|
def exception_handler(exc, context):
|
||||||
"""
|
"""Custom exception handler for DRF framework.
|
||||||
Custom exception handler for DRF framework.
|
|
||||||
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
|
|
||||||
|
|
||||||
|
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
|
||||||
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle
|
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = None
|
response = None
|
||||||
|
|
||||||
# Catch any django validation error, and re-throw a DRF validation error
|
# Catch any django validation error, and re-throw a DRF validation error
|
||||||
|
@ -11,8 +11,7 @@ from common.settings import currency_code_default, currency_codes
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeExchange(SimpleExchangeBackend):
|
class InvenTreeExchange(SimpleExchangeBackend):
|
||||||
"""
|
"""Backend for automatically updating currency exchange rates.
|
||||||
Backend for automatically updating currency exchange rates.
|
|
||||||
|
|
||||||
Uses the exchangerate.host service API
|
Uses the exchangerate.host service API
|
||||||
"""
|
"""
|
||||||
@ -30,11 +29,10 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_response(self, **kwargs):
|
def get_response(self, **kwargs):
|
||||||
"""
|
"""Custom code to get response from server.
|
||||||
Custom code to get response from server.
|
|
||||||
Note: Adds a 5-second timeout
|
Note: Adds a 5-second timeout
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = self.get_url(**kwargs)
|
url = self.get_url(**kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
""" Custom fields used in InvenTree """
|
"""Custom fields used in InvenTree."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -19,13 +19,13 @@ from .validators import allowable_url_schemes
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeURLFormField(FormURLField):
|
class InvenTreeURLFormField(FormURLField):
|
||||||
""" Custom URL form field with custom scheme validators """
|
"""Custom URL form field with custom scheme validators."""
|
||||||
|
|
||||||
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeURLField(models.URLField):
|
class InvenTreeURLField(models.URLField):
|
||||||
""" Custom URL field which has custom scheme validators """
|
"""Custom URL field which has custom scheme validators."""
|
||||||
|
|
||||||
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
default_validators = [validators.URLValidator(schemes=allowable_url_schemes())]
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ class InvenTreeURLField(models.URLField):
|
|||||||
|
|
||||||
|
|
||||||
def money_kwargs():
|
def money_kwargs():
|
||||||
""" returns the database settings for MoneyFields """
|
"""Returns the database settings for MoneyFields."""
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@ -46,9 +46,7 @@ def money_kwargs():
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeModelMoneyField(ModelMoneyField):
|
class InvenTreeModelMoneyField(ModelMoneyField):
|
||||||
"""
|
"""Custom MoneyField for clean migrations while using dynamic currency settings."""
|
||||||
Custom MoneyField for clean migrations while using dynamic currency settings
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
# detect if creating migration
|
# detect if creating migration
|
||||||
@ -73,13 +71,13 @@ class InvenTreeModelMoneyField(ModelMoneyField):
|
|||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
def formfield(self, **kwargs):
|
||||||
""" override form class to use own function """
|
"""Override form class to use own function."""
|
||||||
kwargs['form_class'] = InvenTreeMoneyField
|
kwargs['form_class'] = InvenTreeMoneyField
|
||||||
return super().formfield(**kwargs)
|
return super().formfield(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeMoneyField(MoneyField):
|
class InvenTreeMoneyField(MoneyField):
|
||||||
""" custom MoneyField for clean migrations while using dynamic currency settings """
|
"""Custom MoneyField for clean migrations while using dynamic currency settings."""
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# override initial values with the real info from database
|
# override initial values with the real info from database
|
||||||
kwargs.update(money_kwargs())
|
kwargs.update(money_kwargs())
|
||||||
@ -87,9 +85,7 @@ class InvenTreeMoneyField(MoneyField):
|
|||||||
|
|
||||||
|
|
||||||
class DatePickerFormField(forms.DateField):
|
class DatePickerFormField(forms.DateField):
|
||||||
"""
|
"""Custom date-picker field."""
|
||||||
Custom date-picker field
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
|
||||||
@ -115,10 +111,7 @@ class DatePickerFormField(forms.DateField):
|
|||||||
|
|
||||||
|
|
||||||
def round_decimal(value, places):
|
def round_decimal(value, places):
|
||||||
"""
|
"""Round value to the specified number of places."""
|
||||||
Round value to the specified number of places.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
# see https://docs.python.org/2/library/decimal.html#decimal.Decimal.quantize for options
|
# see https://docs.python.org/2/library/decimal.html#decimal.Decimal.quantize for options
|
||||||
return value.quantize(Decimal(10) ** -places)
|
return value.quantize(Decimal(10) ** -places)
|
||||||
@ -132,11 +125,10 @@ class RoundingDecimalFormField(forms.DecimalField):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def prepare_value(self, value):
|
def prepare_value(self, value):
|
||||||
"""
|
"""Override the 'prepare_value' method, to remove trailing zeros when displaying.
|
||||||
Override the 'prepare_value' method, to remove trailing zeros when displaying.
|
|
||||||
Why? It looks nice!
|
Why? It looks nice!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if type(value) == Decimal:
|
if type(value) == Decimal:
|
||||||
return InvenTree.helpers.normalize(value)
|
return InvenTree.helpers.normalize(value)
|
||||||
else:
|
else:
|
||||||
|
@ -2,8 +2,7 @@ from rest_framework.filters import OrderingFilter
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeOrderingFilter(OrderingFilter):
|
class InvenTreeOrderingFilter(OrderingFilter):
|
||||||
"""
|
"""Custom OrderingFilter class which allows aliased filtering of related fields.
|
||||||
Custom OrderingFilter class which allows aliased filtering of related fields.
|
|
||||||
|
|
||||||
To use, simply specify this filter in the "filter_backends" section.
|
To use, simply specify this filter in the "filter_backends" section.
|
||||||
|
|
||||||
@ -27,9 +26,7 @@ class InvenTreeOrderingFilter(OrderingFilter):
|
|||||||
|
|
||||||
# Attempt to map ordering fields based on provided aliases
|
# Attempt to map ordering fields based on provided aliases
|
||||||
if ordering is not None and aliases is not None:
|
if ordering is not None and aliases is not None:
|
||||||
"""
|
"""Ordering fields should be mapped to separate fields."""
|
||||||
Ordering fields should be mapped to separate fields
|
|
||||||
"""
|
|
||||||
|
|
||||||
ordering_initial = ordering
|
ordering_initial = ordering
|
||||||
ordering = []
|
ordering = []
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Helper forms which subclass Django forms to provide additional functionality."""
|
||||||
Helper forms which subclass Django forms to provide additional functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@ -30,7 +28,7 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
class HelperForm(forms.ModelForm):
|
class HelperForm(forms.ModelForm):
|
||||||
""" Provides simple integration of crispy_forms extension. """
|
"""Provides simple integration of crispy_forms extension."""
|
||||||
|
|
||||||
# Custom field decorations can be specified here, per form class
|
# Custom field decorations can be specified here, per form class
|
||||||
field_prefix = {}
|
field_prefix = {}
|
||||||
@ -117,7 +115,7 @@ class HelperForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class ConfirmForm(forms.Form):
|
class ConfirmForm(forms.Form):
|
||||||
""" Generic confirmation form """
|
"""Generic confirmation form."""
|
||||||
|
|
||||||
confirm = forms.BooleanField(
|
confirm = forms.BooleanField(
|
||||||
required=False, initial=False,
|
required=False, initial=False,
|
||||||
@ -131,8 +129,7 @@ class ConfirmForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class DeleteForm(forms.Form):
|
class DeleteForm(forms.Form):
|
||||||
""" Generic deletion form which provides simple user confirmation
|
"""Generic deletion form which provides simple user confirmation."""
|
||||||
"""
|
|
||||||
|
|
||||||
confirm_delete = forms.BooleanField(
|
confirm_delete = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -148,9 +145,7 @@ class DeleteForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class EditUserForm(HelperForm):
|
class EditUserForm(HelperForm):
|
||||||
"""
|
"""Form for editing user information."""
|
||||||
Form for editing user information
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -161,8 +156,7 @@ class EditUserForm(HelperForm):
|
|||||||
|
|
||||||
|
|
||||||
class SetPasswordForm(HelperForm):
|
class SetPasswordForm(HelperForm):
|
||||||
""" Form for setting user password
|
"""Form for setting user password."""
|
||||||
"""
|
|
||||||
|
|
||||||
enter_password = forms.CharField(max_length=100,
|
enter_password = forms.CharField(max_length=100,
|
||||||
min_length=8,
|
min_length=8,
|
||||||
@ -189,7 +183,7 @@ class SetPasswordForm(HelperForm):
|
|||||||
|
|
||||||
|
|
||||||
class SettingCategorySelectForm(forms.ModelForm):
|
class SettingCategorySelectForm(forms.ModelForm):
|
||||||
""" Form for setting category settings """
|
"""Form for setting category settings."""
|
||||||
|
|
||||||
category = forms.ModelChoiceField(queryset=PartCategory.objects.all())
|
category = forms.ModelChoiceField(queryset=PartCategory.objects.all())
|
||||||
|
|
||||||
@ -220,9 +214,7 @@ class SettingCategorySelectForm(forms.ModelForm):
|
|||||||
|
|
||||||
# override allauth
|
# override allauth
|
||||||
class CustomSignupForm(SignupForm):
|
class CustomSignupForm(SignupForm):
|
||||||
"""
|
"""Override to use dynamic settings."""
|
||||||
Override to use dynamic settings
|
|
||||||
"""
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
||||||
|
|
||||||
@ -261,9 +253,7 @@ class CustomSignupForm(SignupForm):
|
|||||||
|
|
||||||
|
|
||||||
class RegistratonMixin:
|
class RegistratonMixin:
|
||||||
"""
|
"""Mixin to check if registration should be enabled."""
|
||||||
Mixin to check if registration should be enabled
|
|
||||||
"""
|
|
||||||
def is_open_for_signup(self, request, *args, **kwargs):
|
def is_open_for_signup(self, request, *args, **kwargs):
|
||||||
if settings.EMAIL_HOST and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
|
if settings.EMAIL_HOST and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
|
||||||
return super().is_open_for_signup(request, *args, **kwargs)
|
return super().is_open_for_signup(request, *args, **kwargs)
|
||||||
@ -283,20 +273,16 @@ class RegistratonMixin:
|
|||||||
|
|
||||||
|
|
||||||
class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
|
class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
|
||||||
"""
|
"""Override of adapter to use dynamic settings."""
|
||||||
Override of adapter to use dynamic settings
|
|
||||||
"""
|
|
||||||
def send_mail(self, template_prefix, email, context):
|
def send_mail(self, template_prefix, email, context):
|
||||||
"""only send mail if backend configured"""
|
"""Only send mail if backend configured."""
|
||||||
if settings.EMAIL_HOST:
|
if settings.EMAIL_HOST:
|
||||||
return super().send_mail(template_prefix, email, context)
|
return super().send_mail(template_prefix, email, context)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
|
class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
|
||||||
"""
|
"""Override of adapter to use dynamic settings."""
|
||||||
Override of adapter to use dynamic settings
|
|
||||||
"""
|
|
||||||
def is_auto_signup_allowed(self, request, sociallogin):
|
def is_auto_signup_allowed(self, request, sociallogin):
|
||||||
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
|
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
|
||||||
return super().is_auto_signup_allowed(request, sociallogin)
|
return super().is_auto_signup_allowed(request, sociallogin)
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Provides helper functions used throughout the InvenTree project."""
|
||||||
Provides helper functions used throughout the InvenTree project
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@ -27,21 +25,15 @@ from .settings import MEDIA_URL, STATIC_URL
|
|||||||
|
|
||||||
|
|
||||||
def getSetting(key, backup_value=None):
|
def getSetting(key, backup_value=None):
|
||||||
"""
|
"""Shortcut for reading a setting value from the database."""
|
||||||
Shortcut for reading a setting value from the database
|
|
||||||
"""
|
|
||||||
|
|
||||||
return InvenTreeSetting.get_setting(key, backup_value=backup_value)
|
return InvenTreeSetting.get_setting(key, backup_value=backup_value)
|
||||||
|
|
||||||
|
|
||||||
def generateTestKey(test_name):
|
def generateTestKey(test_name):
|
||||||
"""
|
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
|
||||||
Generate a test 'key' for a given test name.
|
|
||||||
This must not have illegal chars as it will be used for dict lookup in a template.
|
|
||||||
|
|
||||||
Tests must be named such that they will have unique keys.
|
Tests must be named such that they will have unique keys.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key = test_name.strip().lower()
|
key = test_name.strip().lower()
|
||||||
key = key.replace(" ", "")
|
key = key.replace(" ", "")
|
||||||
|
|
||||||
@ -52,33 +44,23 @@ def generateTestKey(test_name):
|
|||||||
|
|
||||||
|
|
||||||
def getMediaUrl(filename):
|
def getMediaUrl(filename):
|
||||||
"""
|
"""Return the qualified access path for the given file, under the media directory."""
|
||||||
Return the qualified access path for the given file,
|
|
||||||
under the media directory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return os.path.join(MEDIA_URL, str(filename))
|
return os.path.join(MEDIA_URL, str(filename))
|
||||||
|
|
||||||
|
|
||||||
def getStaticUrl(filename):
|
def getStaticUrl(filename):
|
||||||
"""
|
"""Return the qualified access path for the given file, under the static media directory."""
|
||||||
Return the qualified access path for the given file,
|
|
||||||
under the static media directory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return os.path.join(STATIC_URL, str(filename))
|
return os.path.join(STATIC_URL, str(filename))
|
||||||
|
|
||||||
|
|
||||||
def construct_absolute_url(*arg):
|
def construct_absolute_url(*arg):
|
||||||
"""
|
"""Construct (or attempt to construct) an absolute URL from a relative URL.
|
||||||
Construct (or attempt to construct) an absolute URL from a relative URL.
|
|
||||||
|
|
||||||
This is useful when (for example) sending an email to a user with a link
|
This is useful when (for example) sending an email to a user with a link
|
||||||
to something in the InvenTree web framework.
|
to something in the InvenTree web framework.
|
||||||
|
|
||||||
This requires the BASE_URL configuration option to be set!
|
This requires the BASE_URL configuration option to be set!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL'))
|
base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL'))
|
||||||
|
|
||||||
url = '/'.join(arg)
|
url = '/'.join(arg)
|
||||||
@ -99,23 +81,17 @@ def construct_absolute_url(*arg):
|
|||||||
|
|
||||||
|
|
||||||
def getBlankImage():
|
def getBlankImage():
|
||||||
"""
|
"""Return the qualified path for the 'blank image' placeholder."""
|
||||||
Return the qualified path for the 'blank image' placeholder.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return getStaticUrl("img/blank_image.png")
|
return getStaticUrl("img/blank_image.png")
|
||||||
|
|
||||||
|
|
||||||
def getBlankThumbnail():
|
def getBlankThumbnail():
|
||||||
"""
|
"""Return the qualified path for the 'blank image' thumbnail placeholder."""
|
||||||
Return the qualified path for the 'blank image' thumbnail placeholder.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return getStaticUrl("img/blank_image.thumbnail.png")
|
return getStaticUrl("img/blank_image.thumbnail.png")
|
||||||
|
|
||||||
|
|
||||||
def TestIfImage(img):
|
def TestIfImage(img):
|
||||||
""" Test if an image file is indeed an image """
|
"""Test if an image file is indeed an image."""
|
||||||
try:
|
try:
|
||||||
Image.open(img).verify()
|
Image.open(img).verify()
|
||||||
return True
|
return True
|
||||||
@ -124,7 +100,7 @@ def TestIfImage(img):
|
|||||||
|
|
||||||
|
|
||||||
def TestIfImageURL(url):
|
def TestIfImageURL(url):
|
||||||
""" Test if an image URL (or filename) looks like a valid image format.
|
"""Test if an image URL (or filename) looks like a valid image format.
|
||||||
|
|
||||||
Simply tests the extension against a set of allowed values
|
Simply tests the extension against a set of allowed values
|
||||||
"""
|
"""
|
||||||
@ -137,7 +113,7 @@ def TestIfImageURL(url):
|
|||||||
|
|
||||||
|
|
||||||
def str2bool(text, test=True):
|
def str2bool(text, test=True):
|
||||||
""" Test if a string 'looks' like a boolean value.
|
"""Test if a string 'looks' like a boolean value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text
|
text: Input text
|
||||||
@ -153,10 +129,7 @@ def str2bool(text, test=True):
|
|||||||
|
|
||||||
|
|
||||||
def is_bool(text):
|
def is_bool(text):
|
||||||
"""
|
"""Determine if a string value 'looks' like a boolean."""
|
||||||
Determine if a string value 'looks' like a boolean.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if str2bool(text, True):
|
if str2bool(text, True):
|
||||||
return True
|
return True
|
||||||
elif str2bool(text, False):
|
elif str2bool(text, False):
|
||||||
@ -166,9 +139,7 @@ def is_bool(text):
|
|||||||
|
|
||||||
|
|
||||||
def isNull(text):
|
def isNull(text):
|
||||||
"""
|
"""Test if a string 'looks' like a null value. This is useful for querying the API against a null key.
|
||||||
Test if a string 'looks' like a null value.
|
|
||||||
This is useful for querying the API against a null key.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text
|
text: Input text
|
||||||
@ -176,15 +147,11 @@ def isNull(text):
|
|||||||
Returns:
|
Returns:
|
||||||
True if the text looks like a null value
|
True if the text looks like a null value
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1', '']
|
return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1', '']
|
||||||
|
|
||||||
|
|
||||||
def normalize(d):
|
def normalize(d):
|
||||||
"""
|
"""Normalize a decimal number, and remove exponential formatting."""
|
||||||
Normalize a decimal number, and remove exponential formatting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if type(d) is not Decimal:
|
if type(d) is not Decimal:
|
||||||
d = Decimal(d)
|
d = Decimal(d)
|
||||||
|
|
||||||
@ -195,8 +162,7 @@ def normalize(d):
|
|||||||
|
|
||||||
|
|
||||||
def increment(n):
|
def increment(n):
|
||||||
"""
|
"""Attempt to increment an integer (or a string that looks like an integer!)
|
||||||
Attempt to increment an integer (or a string that looks like an integer!)
|
|
||||||
|
|
||||||
e.g.
|
e.g.
|
||||||
|
|
||||||
@ -204,9 +170,7 @@ def increment(n):
|
|||||||
2 -> 3
|
2 -> 3
|
||||||
AB01 -> AB02
|
AB01 -> AB02
|
||||||
QQQ -> QQQ
|
QQQ -> QQQ
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str(n).strip()
|
value = str(n).strip()
|
||||||
|
|
||||||
# Ignore empty strings
|
# Ignore empty strings
|
||||||
@ -248,10 +212,7 @@ def increment(n):
|
|||||||
|
|
||||||
|
|
||||||
def decimal2string(d):
|
def decimal2string(d):
|
||||||
"""
|
"""Format a Decimal number as a string, stripping out any trailing zeroes or decimal points. Essentially make it look like a whole number if it is one.
|
||||||
Format a Decimal number as a string,
|
|
||||||
stripping out any trailing zeroes or decimal points.
|
|
||||||
Essentially make it look like a whole number if it is one.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
d: A python Decimal object
|
d: A python Decimal object
|
||||||
@ -259,7 +220,6 @@ def decimal2string(d):
|
|||||||
Returns:
|
Returns:
|
||||||
A string representation of the input number
|
A string representation of the input number
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if type(d) is Decimal:
|
if type(d) is Decimal:
|
||||||
d = normalize(d)
|
d = normalize(d)
|
||||||
|
|
||||||
@ -280,8 +240,7 @@ def decimal2string(d):
|
|||||||
|
|
||||||
|
|
||||||
def decimal2money(d, currency=None):
|
def decimal2money(d, currency=None):
|
||||||
"""
|
"""Format a Decimal number as Money.
|
||||||
Format a Decimal number as Money
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
d: A python Decimal object
|
d: A python Decimal object
|
||||||
@ -296,7 +255,7 @@ def decimal2money(d, currency=None):
|
|||||||
|
|
||||||
|
|
||||||
def WrapWithQuotes(text, quote='"'):
|
def WrapWithQuotes(text, quote='"'):
|
||||||
""" Wrap the supplied text with quotes
|
"""Wrap the supplied text with quotes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text to wrap
|
text: Input text to wrap
|
||||||
@ -305,7 +264,6 @@ def WrapWithQuotes(text, quote='"'):
|
|||||||
Returns:
|
Returns:
|
||||||
Supplied text wrapped in quote char
|
Supplied text wrapped in quote char
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not text.startswith(quote):
|
if not text.startswith(quote):
|
||||||
text = quote + text
|
text = quote + text
|
||||||
|
|
||||||
@ -316,7 +274,7 @@ def WrapWithQuotes(text, quote='"'):
|
|||||||
|
|
||||||
|
|
||||||
def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
||||||
""" Generate a string for a barcode. Adds some global InvenTree parameters.
|
"""Generate a string for a barcode. Adds some global InvenTree parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
object_type: string describing the object type e.g. 'StockItem'
|
object_type: string describing the object type e.g. 'StockItem'
|
||||||
@ -363,8 +321,7 @@ def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def GetExportFormats():
|
def GetExportFormats():
|
||||||
""" Return a list of allowable file formats for exporting data """
|
"""Return a list of allowable file formats for exporting data."""
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'csv',
|
'csv',
|
||||||
'tsv',
|
'tsv',
|
||||||
@ -376,8 +333,7 @@ def GetExportFormats():
|
|||||||
|
|
||||||
|
|
||||||
def DownloadFile(data, filename, content_type='application/text', inline=False):
|
def DownloadFile(data, filename, content_type='application/text', inline=False):
|
||||||
"""
|
"""Create a dynamic file for the user to download.
|
||||||
Create a dynamic file for the user to download.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Raw file data (string or bytes)
|
data: Raw file data (string or bytes)
|
||||||
@ -388,7 +344,6 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
|
|||||||
Return:
|
Return:
|
||||||
A StreamingHttpResponse object wrapping the supplied data
|
A StreamingHttpResponse object wrapping the supplied data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filename = WrapWithQuotes(filename)
|
filename = WrapWithQuotes(filename)
|
||||||
|
|
||||||
if type(data) == str:
|
if type(data) == str:
|
||||||
@ -407,8 +362,7 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
|
|||||||
|
|
||||||
|
|
||||||
def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
||||||
"""
|
"""Attempt to extract serial numbers from an input string:
|
||||||
Attempt to extract serial numbers from an input string:
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Serial numbers can be either strings, or integers
|
- Serial numbers can be either strings, or integers
|
||||||
@ -423,7 +377,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
expected_quantity: The number of (unique) serial numbers we expect
|
expected_quantity: The number of (unique) serial numbers we expect
|
||||||
next_number(int): the next possible serial number
|
next_number(int): the next possible serial number
|
||||||
"""
|
"""
|
||||||
|
|
||||||
serials = serials.strip()
|
serials = serials.strip()
|
||||||
|
|
||||||
# fill in the next serial number into the serial
|
# fill in the next serial number into the serial
|
||||||
@ -543,8 +496,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
|
|
||||||
|
|
||||||
def validateFilterString(value, model=None):
|
def validateFilterString(value, model=None):
|
||||||
"""
|
"""Validate that a provided filter string looks like a list of comma-separated key=value pairs.
|
||||||
Validate that a provided filter string looks like a list of comma-separated key=value pairs
|
|
||||||
|
|
||||||
These should nominally match to a valid database filter based on the model being filtered.
|
These should nominally match to a valid database filter based on the model being filtered.
|
||||||
|
|
||||||
@ -559,7 +511,6 @@ def validateFilterString(value, model=None):
|
|||||||
|
|
||||||
Returns a map of key:value pairs
|
Returns a map of key:value pairs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Empty results map
|
# Empty results map
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
@ -605,28 +556,19 @@ def validateFilterString(value, model=None):
|
|||||||
|
|
||||||
|
|
||||||
def addUserPermission(user, permission):
|
def addUserPermission(user, permission):
|
||||||
"""
|
"""Shortcut function for adding a certain permission to a user."""
|
||||||
Shortcut function for adding a certain permission to a user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
perm = Permission.objects.get(codename=permission)
|
perm = Permission.objects.get(codename=permission)
|
||||||
user.user_permissions.add(perm)
|
user.user_permissions.add(perm)
|
||||||
|
|
||||||
|
|
||||||
def addUserPermissions(user, permissions):
|
def addUserPermissions(user, permissions):
|
||||||
"""
|
"""Shortcut function for adding multiple permissions to a user."""
|
||||||
Shortcut function for adding multiple permissions to a user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for permission in permissions:
|
for permission in permissions:
|
||||||
addUserPermission(user, permission)
|
addUserPermission(user, permission)
|
||||||
|
|
||||||
|
|
||||||
def getMigrationFileNames(app):
|
def getMigrationFileNames(app):
|
||||||
"""
|
"""Return a list of all migration filenames for provided app."""
|
||||||
Return a list of all migration filenames for provided app
|
|
||||||
"""
|
|
||||||
|
|
||||||
local_dir = os.path.dirname(os.path.abspath(__file__))
|
local_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
migration_dir = os.path.join(local_dir, '..', app, 'migrations')
|
migration_dir = os.path.join(local_dir, '..', app, 'migrations')
|
||||||
@ -646,10 +588,7 @@ def getMigrationFileNames(app):
|
|||||||
|
|
||||||
|
|
||||||
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||||
"""
|
"""Return the filename associated with the oldest migration."""
|
||||||
Return the filename associated with the oldest migration
|
|
||||||
"""
|
|
||||||
|
|
||||||
oldest_num = -1
|
oldest_num = -1
|
||||||
oldest_file = None
|
oldest_file = None
|
||||||
|
|
||||||
@ -671,10 +610,7 @@ def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
|||||||
|
|
||||||
|
|
||||||
def getNewestMigrationFile(app, exclude_extension=True):
|
def getNewestMigrationFile(app, exclude_extension=True):
|
||||||
"""
|
"""Return the filename associated with the newest migration."""
|
||||||
Return the filename associated with the newest migration
|
|
||||||
"""
|
|
||||||
|
|
||||||
newest_file = None
|
newest_file = None
|
||||||
newest_num = -1
|
newest_num = -1
|
||||||
|
|
||||||
@ -692,8 +628,7 @@ def getNewestMigrationFile(app, exclude_extension=True):
|
|||||||
|
|
||||||
|
|
||||||
def clean_decimal(number):
|
def clean_decimal(number):
|
||||||
""" Clean-up decimal value """
|
"""Clean-up decimal value."""
|
||||||
|
|
||||||
# Check if empty
|
# Check if empty
|
||||||
if number is None or number == '' or number == 0:
|
if number is None or number == '' or number == 0:
|
||||||
return Decimal(0)
|
return Decimal(0)
|
||||||
@ -729,7 +664,7 @@ def clean_decimal(number):
|
|||||||
|
|
||||||
|
|
||||||
def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
|
def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
|
||||||
"""lookup method for the GenericForeignKey fields
|
"""Lookup method for the GenericForeignKey fields.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
- obj: object that will be resolved
|
- obj: object that will be resolved
|
||||||
@ -769,9 +704,7 @@ def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = '
|
|||||||
|
|
||||||
|
|
||||||
def inheritors(cls):
|
def inheritors(cls):
|
||||||
"""
|
"""Return all classes that are subclasses from the supplied cls."""
|
||||||
Return all classes that are subclasses from the supplied cls
|
|
||||||
"""
|
|
||||||
subcls = set()
|
subcls = set()
|
||||||
work = [cls]
|
work = [cls]
|
||||||
while work:
|
while work:
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Custom management command to cleanup old settings that are not defined anymore."""
|
||||||
Custom management command to cleanup old settings that are not defined anymore
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -10,9 +8,7 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Cleanup old (undefined) settings in the database."""
|
||||||
Cleanup old (undefined) settings in the database
|
|
||||||
"""
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Custom management command to prerender files."""
|
||||||
Custom management command to prerender files
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -13,7 +11,7 @@ from django.utils.translation import override as lang_over
|
|||||||
|
|
||||||
|
|
||||||
def render_file(file_name, source, target, locales, ctx):
|
def render_file(file_name, source, target, locales, ctx):
|
||||||
""" renders a file into all provided locales """
|
"""Renders a file into all provided locales."""
|
||||||
for locale in locales:
|
for locale in locales:
|
||||||
target_file = os.path.join(target, locale + '.' + file_name)
|
target_file = os.path.join(target, locale + '.' + file_name)
|
||||||
with open(target_file, 'w') as localised_file:
|
with open(target_file, 'w') as localised_file:
|
||||||
@ -23,9 +21,7 @@ def render_file(file_name, source, target, locales, ctx):
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Django command to prerender files."""
|
||||||
django command to prerender files
|
|
||||||
"""
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
# static directories
|
# static directories
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Custom management command to rebuild all MPTT models.
|
||||||
Custom management command to rebuild all MPTT models
|
|
||||||
|
|
||||||
- This is crucial after importing any fixtures, etc
|
- This is crucial after importing any fixtures, etc
|
||||||
"""
|
"""
|
||||||
@ -8,9 +7,7 @@ from django.core.management.base import BaseCommand
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Rebuild all database models which leverage the MPTT structure."""
|
||||||
Rebuild all database models which leverage the MPTT structure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Custom management command to rebuild thumbnail images.
|
||||||
Custom management command to rebuild thumbnail images
|
|
||||||
|
|
||||||
- May be required after importing a new dataset, for example
|
- May be required after importing a new dataset, for example
|
||||||
"""
|
"""
|
||||||
@ -20,15 +19,10 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Rebuild all thumbnail images."""
|
||||||
Rebuild all thumbnail images
|
|
||||||
"""
|
|
||||||
|
|
||||||
def rebuild_thumbnail(self, model):
|
def rebuild_thumbnail(self, model):
|
||||||
"""
|
"""Rebuild the thumbnail specified by the "image" field of the provided model."""
|
||||||
Rebuild the thumbnail specified by the "image" field of the provided model
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not model.image:
|
if not model.image:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
"""
|
"""Custom management command to remove MFA for a user."""
|
||||||
Custom management command to remove MFA for a user
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Remove MFA for a user."""
|
||||||
Remove MFA for a user
|
|
||||||
"""
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('mail', type=str)
|
parser.add_argument('mail', type=str)
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Custom management command, wait for the database to be ready!"""
|
||||||
Custom management command, wait for the database to be ready!
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@ -10,9 +8,7 @@ from django.db.utils import ImproperlyConfigured, OperationalError
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""Django command to pause execution until the database is ready."""
|
||||||
django command to pause execution until the database is ready
|
|
||||||
"""
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -12,8 +12,7 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeMetadata(SimpleMetadata):
|
class InvenTreeMetadata(SimpleMetadata):
|
||||||
"""
|
"""Custom metadata class for the DRF API.
|
||||||
Custom metadata class for the DRF API.
|
|
||||||
|
|
||||||
This custom metadata class imits the available "actions",
|
This custom metadata class imits the available "actions",
|
||||||
based on the user's role permissions.
|
based on the user's role permissions.
|
||||||
@ -23,7 +22,6 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
Additionally, we include some extra information about database models,
|
Additionally, we include some extra information about database models,
|
||||||
so we can perform lookup for ForeignKey related fields.
|
so we can perform lookup for ForeignKey related fields.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def determine_metadata(self, request, view):
|
def determine_metadata(self, request, view):
|
||||||
@ -106,11 +104,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
def get_serializer_info(self, serializer):
|
def get_serializer_info(self, serializer):
|
||||||
"""
|
"""Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value."""
|
||||||
Override get_serializer_info so that we can add 'default' values
|
|
||||||
to any fields whose Meta.model specifies a default value
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.serializer = serializer
|
self.serializer = serializer
|
||||||
|
|
||||||
serializer_info = super().get_serializer_info(serializer)
|
serializer_info = super().get_serializer_info(serializer)
|
||||||
@ -208,10 +202,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if instance is not None:
|
if instance is not None:
|
||||||
"""
|
"""If there is an instance associated with this API View, introspect that instance to find any specific API info."""
|
||||||
If there is an instance associated with this API View,
|
|
||||||
introspect that instance to find any specific API info.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if hasattr(instance, 'api_instance_filters'):
|
if hasattr(instance, 'api_instance_filters'):
|
||||||
|
|
||||||
@ -233,13 +224,10 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
return serializer_info
|
return serializer_info
|
||||||
|
|
||||||
def get_field_info(self, field):
|
def get_field_info(self, field):
|
||||||
"""
|
"""Given an instance of a serializer field, return a dictionary of metadata about it.
|
||||||
Given an instance of a serializer field, return a dictionary
|
|
||||||
of metadata about it.
|
|
||||||
|
|
||||||
We take the regular DRF metadata and add our own unique flavor
|
We take the regular DRF metadata and add our own unique flavor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Run super method first
|
# Run super method first
|
||||||
field_info = super().get_field_info(field)
|
field_info = super().get_field_info(field)
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ class AuthRequiredMiddleware(object):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
"""
|
"""
|
||||||
Normally, a web-based session would use csrftoken based authentication.
|
Normally, a web-based session would use csrftoken based authentication.
|
||||||
|
|
||||||
However when running an external application (e.g. the InvenTree app or Python library),
|
However when running an external application (e.g. the InvenTree app or Python library),
|
||||||
we must validate the user token manually.
|
we must validate the user token manually.
|
||||||
"""
|
"""
|
||||||
@ -105,7 +106,7 @@ url_matcher = re_path('', include(frontendpatterns))
|
|||||||
|
|
||||||
|
|
||||||
class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
||||||
"""check if user is required to have MFA enabled"""
|
"""check if user is required to have MFA enabled."""
|
||||||
def require_2fa(self, request):
|
def require_2fa(self, request):
|
||||||
# Superusers are require to have 2FA.
|
# Superusers are require to have 2FA.
|
||||||
try:
|
try:
|
||||||
@ -117,7 +118,7 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
|||||||
|
|
||||||
|
|
||||||
class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
||||||
"""This function ensures only frontend code triggers the MFA auth cycle"""
|
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
try:
|
try:
|
||||||
if not url_matcher.resolve(request.path[1:]):
|
if not url_matcher.resolve(request.path[1:]):
|
||||||
@ -127,9 +128,7 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
||||||
"""
|
"""Middleware to check if HTTP-header based auth is enabled and to set it up."""
|
||||||
Middleware to check if HTTP-header based auth is enabled and to set it up
|
|
||||||
"""
|
|
||||||
header = settings.REMOTE_LOGIN_HEADER
|
header = settings.REMOTE_LOGIN_HEADER
|
||||||
|
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Generic models which provide extra functionality over base Django model types."""
|
||||||
Generic models which provide extra functionality over base Django model types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -25,25 +23,21 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
def rename_attachment(instance, filename):
|
def rename_attachment(instance, filename):
|
||||||
"""
|
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
|
||||||
Function for renaming an attachment file.
|
|
||||||
The subdirectory for the uploaded file is determined by the implementing class.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance: Instance of a PartAttachment object
|
instance: Instance of a PartAttachment object
|
||||||
filename: name of uploaded file
|
filename: name of uploaded file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
path to store file, format: '<subdir>/<id>/filename'
|
path to store file, format: '<subdir>/<id>/filename'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Construct a path to store a file attachment for a given model type
|
# Construct a path to store a file attachment for a given model type
|
||||||
return os.path.join(instance.getSubdir(), filename)
|
return os.path.join(instance.getSubdir(), filename)
|
||||||
|
|
||||||
|
|
||||||
class DataImportMixin(object):
|
class DataImportMixin(object):
|
||||||
"""
|
"""Model mixin class which provides support for 'data import' functionality.
|
||||||
Model mixin class which provides support for 'data import' functionality.
|
|
||||||
|
|
||||||
Models which implement this mixin should provide information on the fields available for import
|
Models which implement this mixin should provide information on the fields available for import
|
||||||
"""
|
"""
|
||||||
@ -53,12 +47,10 @@ class DataImportMixin(object):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_import_fields(cls):
|
def get_import_fields(cls):
|
||||||
"""
|
"""Return all available import fields.
|
||||||
Return all available import fields
|
|
||||||
|
|
||||||
Where information on a particular field is not explicitly provided,
|
Where information on a particular field is not explicitly provided,
|
||||||
introspect the base model to (attempt to) find that information.
|
introspect the base model to (attempt to) find that information.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
fields = cls.IMPORT_FIELDS
|
fields = cls.IMPORT_FIELDS
|
||||||
|
|
||||||
@ -85,7 +77,7 @@ class DataImportMixin(object):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_required_import_fields(cls):
|
def get_required_import_fields(cls):
|
||||||
""" Return all *required* import fields """
|
"""Return all *required* import fields."""
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
||||||
for name, field in cls.get_import_fields().items():
|
for name, field in cls.get_import_fields().items():
|
||||||
@ -98,8 +90,7 @@ class DataImportMixin(object):
|
|||||||
|
|
||||||
|
|
||||||
class ReferenceIndexingMixin(models.Model):
|
class ReferenceIndexingMixin(models.Model):
|
||||||
"""
|
"""A mixin for keeping track of numerical copies of the "reference" field.
|
||||||
A mixin for keeping track of numerical copies of the "reference" field.
|
|
||||||
|
|
||||||
!!DANGER!! always add `ReferenceIndexingSerializerMixin`to all your models serializers to
|
!!DANGER!! always add `ReferenceIndexingSerializerMixin`to all your models serializers to
|
||||||
ensure the reference field is not too big
|
ensure the reference field is not too big
|
||||||
@ -155,7 +146,7 @@ def extract_int(reference, clip=0x7fffffff):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachment(models.Model):
|
class InvenTreeAttachment(models.Model):
|
||||||
""" Provides an abstracted class for managing file attachments.
|
"""Provides an abstracted class for managing file attachments.
|
||||||
|
|
||||||
An attachment can be either an uploaded file, or an external URL
|
An attachment can be either an uploaded file, or an external URL
|
||||||
|
|
||||||
@ -167,11 +158,10 @@ class InvenTreeAttachment(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def getSubdir(self):
|
def getSubdir(self):
|
||||||
"""
|
"""Return the subdirectory under which attachments should be stored.
|
||||||
Return the subdirectory under which attachments should be stored.
|
|
||||||
Note: Re-implement this for each subclass of InvenTreeAttachment
|
Note: Re-implement this for each subclass of InvenTreeAttachment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return "attachments"
|
return "attachments"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@ -222,15 +212,13 @@ class InvenTreeAttachment(models.Model):
|
|||||||
|
|
||||||
@basename.setter
|
@basename.setter
|
||||||
def basename(self, fn):
|
def basename(self, fn):
|
||||||
"""
|
"""Function to rename the attachment file.
|
||||||
Function to rename the attachment file.
|
|
||||||
|
|
||||||
- Filename cannot be empty
|
- Filename cannot be empty
|
||||||
- Filename cannot contain illegal characters
|
- Filename cannot contain illegal characters
|
||||||
- Filename must specify an extension
|
- Filename must specify an extension
|
||||||
- Filename cannot match an existing file
|
- Filename cannot match an existing file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fn = fn.strip()
|
fn = fn.strip()
|
||||||
|
|
||||||
if len(fn) == 0:
|
if len(fn) == 0:
|
||||||
@ -291,7 +279,7 @@ class InvenTreeAttachment(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeTree(MPTTModel):
|
class InvenTreeTree(MPTTModel):
|
||||||
""" Provides an abstracted self-referencing tree model for data categories.
|
"""Provides an abstracted self-referencing tree model for data categories.
|
||||||
|
|
||||||
- Each Category has one parent Category, which can be blank (for a top-level Category).
|
- Each Category has one parent Category, which can be blank (for a top-level Category).
|
||||||
- Each Category can have zero-or-more child Categor(y/ies)
|
- Each Category can have zero-or-more child Categor(y/ies)
|
||||||
@ -303,10 +291,7 @@ class InvenTreeTree(MPTTModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def api_instance_filters(self):
|
def api_instance_filters(self):
|
||||||
"""
|
"""Instance filters for InvenTreeTree models."""
|
||||||
Instance filters for InvenTreeTree models
|
|
||||||
"""
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'parent': {
|
'parent': {
|
||||||
'exclude_tree': self.pk,
|
'exclude_tree': self.pk,
|
||||||
@ -356,7 +341,7 @@ class InvenTreeTree(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def item_count(self):
|
def item_count(self):
|
||||||
""" Return the number of items which exist *under* this node in the tree.
|
"""Return the number of items which exist *under* this node in the tree.
|
||||||
|
|
||||||
Here an 'item' is considered to be the 'leaf' at the end of each branch,
|
Here an 'item' is considered to be the 'leaf' at the end of each branch,
|
||||||
and the exact nature here will depend on the class implementation.
|
and the exact nature here will depend on the class implementation.
|
||||||
@ -366,30 +351,29 @@ class InvenTreeTree(MPTTModel):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def getUniqueParents(self):
|
def getUniqueParents(self):
|
||||||
""" Return a flat set of all parent items that exist above this node.
|
"""Return a flat set of all parent items that exist above this node.
|
||||||
|
|
||||||
If any parents are repeated (which would be very bad!), the process is halted
|
If any parents are repeated (which would be very bad!), the process is halted
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.get_ancestors()
|
return self.get_ancestors()
|
||||||
|
|
||||||
def getUniqueChildren(self, include_self=True):
|
def getUniqueChildren(self, include_self=True):
|
||||||
""" Return a flat set of all child items that exist under this node.
|
"""Return a flat set of all child items that exist under this node.
|
||||||
|
|
||||||
If any child items are repeated, the repetitions are omitted.
|
If any child items are repeated, the repetitions are omitted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.get_descendants(include_self=include_self)
|
return self.get_descendants(include_self=include_self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_children(self):
|
def has_children(self):
|
||||||
""" True if there are any children under this item """
|
"""True if there are any children under this item."""
|
||||||
return self.getUniqueChildren(include_self=False).count() > 0
|
return self.getUniqueChildren(include_self=False).count() > 0
|
||||||
|
|
||||||
def getAcceptableParents(self):
|
def getAcceptableParents(self):
|
||||||
""" Returns a list of acceptable parent items within this model
|
"""Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item.
|
||||||
Acceptable parents are ones which are not underneath this item.
|
|
||||||
Setting the parent of an item to its own child results in recursion.
|
Setting the parent of an item to its own child results in recursion.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
contents = ContentType.objects.get_for_model(type(self))
|
contents = ContentType.objects.get_for_model(type(self))
|
||||||
|
|
||||||
available = contents.get_all_objects_for_this_type()
|
available = contents.get_all_objects_for_this_type()
|
||||||
@ -407,17 +391,16 @@ class InvenTreeTree(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def parentpath(self):
|
def parentpath(self):
|
||||||
""" Get the parent path of this category
|
"""Get the parent path of this category.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of category names from the top level to the parent of this category
|
List of category names from the top level to the parent of this category
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return [a for a in self.get_ancestors()]
|
return [a for a in self.get_ancestors()]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
""" Get the complete part of this category.
|
"""Get the complete part of this category.
|
||||||
|
|
||||||
e.g. ["Top", "Second", "Third", "This"]
|
e.g. ["Top", "Second", "Third", "This"]
|
||||||
|
|
||||||
@ -428,25 +411,23 @@ class InvenTreeTree(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def pathstring(self):
|
def pathstring(self):
|
||||||
""" Get a string representation for the path of this item.
|
"""Get a string representation for the path of this item.
|
||||||
|
|
||||||
e.g. "Top/Second/Third/This"
|
e.g. "Top/Second/Third/This"
|
||||||
"""
|
"""
|
||||||
return '/'.join([item.name for item in self.path])
|
return '/'.join([item.name for item in self.path])
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
""" String representation of a category is the full path to that category """
|
"""String representation of a category is the full path to that category."""
|
||||||
|
|
||||||
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
|
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
|
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
|
||||||
def before_delete_tree_item(sender, instance, using, **kwargs):
|
def before_delete_tree_item(sender, instance, using, **kwargs):
|
||||||
""" Receives pre_delete signal from InvenTreeTree object.
|
"""Receives pre_delete signal from InvenTreeTree object.
|
||||||
|
|
||||||
Before an item is deleted, update each child object to point to the parent of the object being deleted.
|
Before an item is deleted, update each child object to point to the parent of the object being deleted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Update each tree item below this one
|
# Update each tree item below this one
|
||||||
for child in instance.children.all():
|
for child in instance.children.all():
|
||||||
child.parent = instance.parent
|
child.parent = instance.parent
|
||||||
|
@ -4,9 +4,7 @@ import users.models
|
|||||||
|
|
||||||
|
|
||||||
class RolePermission(permissions.BasePermission):
|
class RolePermission(permissions.BasePermission):
|
||||||
"""
|
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
|
||||||
Role mixin for API endpoints, allowing us to specify the user "role"
|
|
||||||
which is required for certain operations.
|
|
||||||
|
|
||||||
Each endpoint can have one or more of the following actions:
|
Each endpoint can have one or more of the following actions:
|
||||||
- GET
|
- GET
|
||||||
@ -25,14 +23,10 @@ class RolePermission(permissions.BasePermission):
|
|||||||
to perform the specified action.
|
to perform the specified action.
|
||||||
|
|
||||||
For example, a DELETE action will be rejected unless the user has the "part.remove" permission
|
For example, a DELETE action will be rejected unless the user has the "part.remove" permission
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""
|
"""Determine if the current user has the specified permissions."""
|
||||||
Determine if the current user has the specified permissions
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Superuser can do it all
|
# Superuser can do it all
|
||||||
|
@ -2,30 +2,21 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def isInTestMode():
|
def isInTestMode():
|
||||||
"""
|
"""Returns True if the database is in testing mode."""
|
||||||
Returns True if the database is in testing mode
|
|
||||||
"""
|
|
||||||
|
|
||||||
return 'test' in sys.argv
|
return 'test' in sys.argv
|
||||||
|
|
||||||
|
|
||||||
def isImportingData():
|
def isImportingData():
|
||||||
"""
|
"""Returns True if the database is currently importing data, e.g. 'loaddata' command is performed."""
|
||||||
Returns True if the database is currently importing data,
|
|
||||||
e.g. 'loaddata' command is performed
|
|
||||||
"""
|
|
||||||
|
|
||||||
return 'loaddata' in sys.argv
|
return 'loaddata' in sys.argv
|
||||||
|
|
||||||
|
|
||||||
def canAppAccessDatabase(allow_test=False):
|
def canAppAccessDatabase(allow_test=False):
|
||||||
"""
|
"""Returns True if the apps.py file can access database records.
|
||||||
Returns True if the apps.py file can access database records.
|
|
||||||
|
|
||||||
There are some circumstances where we don't want the ready function in apps.py
|
There are some circumstances where we don't want the ready function in apps.py
|
||||||
to touch the database
|
to touch the database
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If any of the following management commands are being executed,
|
# If any of the following management commands are being executed,
|
||||||
# prevent custom "on load" code from running!
|
# prevent custom "on load" code from running!
|
||||||
excluded_commands = [
|
excluded_commands = [
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Serializers used in various InvenTree apps."""
|
||||||
Serializers used in various InvenTree apps
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@ -26,9 +24,7 @@ from .models import extract_int
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeMoneySerializer(MoneyField):
|
class InvenTreeMoneySerializer(MoneyField):
|
||||||
"""
|
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
|
||||||
Custom serializer for 'MoneyField',
|
|
||||||
which ensures that passed values are numerically valid
|
|
||||||
|
|
||||||
Ref: https://github.com/django-money/django-money/blob/master/djmoney/contrib/django_rest_framework/fields.py
|
Ref: https://github.com/django-money/django-money/blob/master/djmoney/contrib/django_rest_framework/fields.py
|
||||||
"""
|
"""
|
||||||
@ -41,10 +37,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_value(self, data):
|
def get_value(self, data):
|
||||||
"""
|
"""Test that the returned amount is a valid Decimal."""
|
||||||
Test that the returned amount is a valid Decimal
|
|
||||||
"""
|
|
||||||
|
|
||||||
amount = super(DecimalField, self).get_value(data)
|
amount = super(DecimalField, self).get_value(data)
|
||||||
|
|
||||||
# Convert an empty string to None
|
# Convert an empty string to None
|
||||||
@ -68,7 +61,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
|||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
""" Serializer for User - provides all fields """
|
"""Serializer for User - provides all fields"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -76,7 +69,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserSerializerBrief(serializers.ModelSerializer):
|
class UserSerializerBrief(serializers.ModelSerializer):
|
||||||
""" Serializer for User - provides limited information """
|
"""Serializer for User - provides limited information"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -87,17 +80,10 @@ class UserSerializerBrief(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeModelSerializer(serializers.ModelSerializer):
|
class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
|
||||||
Inherits the standard Django ModelSerializer class,
|
|
||||||
but also ensures that the underlying model class data are checked on validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, instance=None, data=empty, **kwargs):
|
def __init__(self, instance=None, data=empty, **kwargs):
|
||||||
"""
|
"""Custom __init__ routine to ensure that *default* values (as specified in the ORM) are used by the DRF serializers, *if* the values are not provided by the user."""
|
||||||
Custom __init__ routine to ensure that *default* values (as specified in the ORM)
|
|
||||||
are used by the DRF serializers, *if* the values are not provided by the user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# If instance is None, we are creating a new instance
|
# If instance is None, we are creating a new instance
|
||||||
if instance is None and data is not empty:
|
if instance is None and data is not empty:
|
||||||
|
|
||||||
@ -118,6 +104,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
Update the field IF (and ONLY IF):
|
Update the field IF (and ONLY IF):
|
||||||
|
|
||||||
- The field has a specified default value
|
- The field has a specified default value
|
||||||
- The field does not already have a value set
|
- The field does not already have a value set
|
||||||
"""
|
"""
|
||||||
@ -137,11 +124,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
super().__init__(instance, data, **kwargs)
|
super().__init__(instance, data, **kwargs)
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
"""
|
"""Construct initial data for the serializer.
|
||||||
Construct initial data for the serializer.
|
|
||||||
Use the 'default' values specified by the django model definition
|
Use the 'default' values specified by the django model definition
|
||||||
"""
|
"""
|
||||||
|
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
|
|
||||||
# Are we creating a new instance?
|
# Are we creating a new instance?
|
||||||
@ -168,11 +154,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return initials
|
return initials
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
"""
|
"""Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError."""
|
||||||
Catch any django ValidationError thrown at the moment save() is called,
|
|
||||||
and re-throw as a DRF ValidationError
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().save(**kwargs)
|
super().save(**kwargs)
|
||||||
except (ValidationError, DjangoValidationError) as exc:
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
@ -181,10 +163,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return self.instance
|
return self.instance
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
"""
|
"""Catch any django ValidationError, and re-throw as a DRF ValidationError."""
|
||||||
Catch any django ValidationError, and re-throw as a DRF ValidationError
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
instance = super().update(instance, validated_data)
|
instance = super().update(instance, validated_data)
|
||||||
except (ValidationError, DjangoValidationError) as exc:
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
@ -193,12 +172,11 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
def run_validation(self, data=empty):
|
def run_validation(self, data=empty):
|
||||||
"""
|
"""Perform serializer validation.
|
||||||
Perform serializer validation.
|
|
||||||
In addition to running validators on the serializer fields,
|
In addition to running validators on the serializer fields,
|
||||||
this class ensures that the underlying model is also validated.
|
this class ensures that the underlying model is also validated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Run any native validation checks first (may raise a ValidationError)
|
# Run any native validation checks first (may raise a ValidationError)
|
||||||
data = super().run_validation(data)
|
data = super().run_validation(data)
|
||||||
|
|
||||||
@ -237,10 +215,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ReferenceIndexingSerializerMixin():
|
class ReferenceIndexingSerializerMixin():
|
||||||
"""
|
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
|
||||||
This serializer mixin ensures the the reference is not to big / small
|
|
||||||
for the BigIntegerField
|
|
||||||
"""
|
|
||||||
def validate_reference(self, value):
|
def validate_reference(self, value):
|
||||||
if extract_int(value) > models.BigIntegerField.MAX_BIGINT:
|
if extract_int(value) > models.BigIntegerField.MAX_BIGINT:
|
||||||
raise serializers.ValidationError('reference is to to big')
|
raise serializers.ValidationError('reference is to to big')
|
||||||
@ -248,9 +223,7 @@ class ReferenceIndexingSerializerMixin():
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""
|
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||||
Override the DRF native FileField serializer,
|
|
||||||
to remove the leading server path.
|
|
||||||
|
|
||||||
For example, the FileField might supply something like:
|
For example, the FileField might supply something like:
|
||||||
|
|
||||||
@ -277,8 +250,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||||
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
|
||||||
|
|
||||||
The only real addition here is that we support "renaming" of the attachment file.
|
The only real addition here is that we support "renaming" of the attachment file.
|
||||||
"""
|
"""
|
||||||
@ -298,8 +270,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||||
"""
|
"""Custom image serializer.
|
||||||
Custom image serializer.
|
|
||||||
On upload, validate that the file is a valid image file
|
On upload, validate that the file is a valid image file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -312,8 +284,7 @@ class InvenTreeImageSerializerField(serializers.ImageField):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeDecimalField(serializers.FloatField):
|
class InvenTreeDecimalField(serializers.FloatField):
|
||||||
"""
|
"""Custom serializer for decimal fields. Solves the following issues:
|
||||||
Custom serializer for decimal fields. Solves the following issues:
|
|
||||||
|
|
||||||
- The normal DRF DecimalField renders values with trailing zeros
|
- The normal DRF DecimalField renders values with trailing zeros
|
||||||
- Using a FloatField can result in rounding issues: https://code.djangoproject.com/ticket/30290
|
- Using a FloatField can result in rounding issues: https://code.djangoproject.com/ticket/30290
|
||||||
@ -329,8 +300,7 @@ class InvenTreeDecimalField(serializers.FloatField):
|
|||||||
|
|
||||||
|
|
||||||
class DataFileUploadSerializer(serializers.Serializer):
|
class DataFileUploadSerializer(serializers.Serializer):
|
||||||
"""
|
"""Generic serializer for uploading a data file, and extracting a dataset.
|
||||||
Generic serializer for uploading a data file, and extracting a dataset.
|
|
||||||
|
|
||||||
- Validates uploaded file
|
- Validates uploaded file
|
||||||
- Extracts column names
|
- Extracts column names
|
||||||
@ -353,10 +323,7 @@ class DataFileUploadSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_data_file(self, data_file):
|
def validate_data_file(self, data_file):
|
||||||
"""
|
"""Perform validation checks on the uploaded data file."""
|
||||||
Perform validation checks on the uploaded data file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.filename = data_file.name
|
self.filename = data_file.name
|
||||||
|
|
||||||
name, ext = os.path.splitext(data_file.name)
|
name, ext = os.path.splitext(data_file.name)
|
||||||
@ -406,15 +373,13 @@ class DataFileUploadSerializer(serializers.Serializer):
|
|||||||
return data_file
|
return data_file
|
||||||
|
|
||||||
def match_column(self, column_name, field_names, exact=False):
|
def match_column(self, column_name, field_names, exact=False):
|
||||||
"""
|
"""Attempt to match a column name (from the file) to a field (defined in the model)
|
||||||
Attempt to match a column name (from the file) to a field (defined in the model)
|
|
||||||
|
|
||||||
Order of matching is:
|
Order of matching is:
|
||||||
- Direct match
|
- Direct match
|
||||||
- Case insensitive match
|
- Case insensitive match
|
||||||
- Fuzzy match
|
- Fuzzy match
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not column_name:
|
if not column_name:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -439,10 +404,7 @@ class DataFileUploadSerializer(serializers.Serializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def extract_data(self):
|
def extract_data(self):
|
||||||
"""
|
"""Returns dataset extracted from the file."""
|
||||||
Returns dataset extracted from the file
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Provide a dict of available import fields for the model
|
# Provide a dict of available import fields for the model
|
||||||
model_fields = {}
|
model_fields = {}
|
||||||
|
|
||||||
@ -487,8 +449,7 @@ class DataFileUploadSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class DataFileExtractSerializer(serializers.Serializer):
|
class DataFileExtractSerializer(serializers.Serializer):
|
||||||
"""
|
"""Generic serializer for extracting data from an imported dataset.
|
||||||
Generic serializer for extracting data from an imported dataset.
|
|
||||||
|
|
||||||
- User provides an array of matched headers
|
- User provides an array of matched headers
|
||||||
- User provides an array of raw data rows
|
- User provides an array of raw data rows
|
||||||
@ -548,9 +509,7 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
for row in self.rows:
|
for row in self.rows:
|
||||||
"""
|
"""Optionally pre-process each row, before sending back to the client."""
|
||||||
Optionally pre-process each row, before sending back to the client
|
|
||||||
"""
|
|
||||||
|
|
||||||
processed_row = self.process_row(self.row_to_dict(row))
|
processed_row = self.process_row(self.row_to_dict(row))
|
||||||
|
|
||||||
@ -567,22 +526,17 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def process_row(self, row):
|
def process_row(self, row):
|
||||||
"""
|
"""Process a 'row' of data, which is a mapped column:value dict.
|
||||||
Process a 'row' of data, which is a mapped column:value dict
|
|
||||||
|
|
||||||
Returns either a mapped column:value dict, or None.
|
Returns either a mapped column:value dict, or None.
|
||||||
|
|
||||||
If the function returns None, the column is ignored!
|
If the function returns None, the column is ignored!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Default implementation simply returns the original row data
|
# Default implementation simply returns the original row data
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def row_to_dict(self, row):
|
def row_to_dict(self, row):
|
||||||
"""
|
"""Convert a "row" to a named data dict."""
|
||||||
Convert a "row" to a named data dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
row_dict = {
|
row_dict = {
|
||||||
'errors': {},
|
'errors': {},
|
||||||
}
|
}
|
||||||
@ -598,10 +552,7 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
return row_dict
|
return row_dict
|
||||||
|
|
||||||
def validate_extracted_columns(self):
|
def validate_extracted_columns(self):
|
||||||
"""
|
"""Perform custom validation of header mapping."""
|
||||||
Perform custom validation of header mapping.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.TARGET_MODEL:
|
if self.TARGET_MODEL:
|
||||||
try:
|
try:
|
||||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
@ -631,7 +582,5 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
cols_seen.add(col)
|
cols_seen.add(col)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""
|
"""No "save" action for this serializer."""
|
||||||
No "save" action for this serializer
|
|
||||||
"""
|
|
||||||
...
|
...
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Django settings for InvenTree project.
|
||||||
Django settings for InvenTree project.
|
|
||||||
|
|
||||||
In practice the settings in this file should not be adjusted,
|
In practice the settings in this file should not be adjusted,
|
||||||
instead settings can be configured in the config.yaml file
|
instead settings can be configured in the config.yaml file
|
||||||
@ -8,7 +7,6 @@ located in the top level project directory.
|
|||||||
This allows implementation configuration to be hidden from source control,
|
This allows implementation configuration to be hidden from source control,
|
||||||
as well as separate configuration parameters from the more complex
|
as well as separate configuration parameters from the more complex
|
||||||
database setup in this file.
|
database setup in this file.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Provides system status functionality checks."""
|
||||||
Provides system status functionality checks.
|
|
||||||
"""
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -19,10 +17,7 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def is_worker_running(**kwargs):
|
def is_worker_running(**kwargs):
|
||||||
"""
|
"""Return True if the background worker process is oprational."""
|
||||||
Return True if the background worker process is oprational
|
|
||||||
"""
|
|
||||||
|
|
||||||
clusters = Stat.get_all()
|
clusters = Stat.get_all()
|
||||||
|
|
||||||
if len(clusters) > 0:
|
if len(clusters) > 0:
|
||||||
@ -48,12 +43,10 @@ def is_worker_running(**kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def is_email_configured():
|
def is_email_configured():
|
||||||
"""
|
"""Check if email backend is configured.
|
||||||
Check if email backend is configured.
|
|
||||||
|
|
||||||
NOTE: This does not check if the configuration is valid!
|
NOTE: This does not check if the configuration is valid!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
configured = True
|
configured = True
|
||||||
|
|
||||||
if InvenTree.ready.isInTestMode():
|
if InvenTree.ready.isInTestMode():
|
||||||
@ -87,12 +80,10 @@ def is_email_configured():
|
|||||||
|
|
||||||
|
|
||||||
def check_system_health(**kwargs):
|
def check_system_health(**kwargs):
|
||||||
"""
|
"""Check that the InvenTree system is running OK.
|
||||||
Check that the InvenTree system is running OK.
|
|
||||||
|
|
||||||
Returns True if all system checks pass.
|
Returns True if all system checks pass.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = True
|
result = True
|
||||||
|
|
||||||
if InvenTree.ready.isInTestMode():
|
if InvenTree.ready.isInTestMode():
|
||||||
|
@ -2,8 +2,8 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
|
|
||||||
class StatusCode:
|
class StatusCode:
|
||||||
"""
|
"""Base class for representing a set of StatusCodes.
|
||||||
Base class for representing a set of StatusCodes.
|
|
||||||
This is used to map a set of integer values to text.
|
This is used to map a set of integer values to text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -11,10 +11,7 @@ class StatusCode:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, key, large=False):
|
def render(cls, key, large=False):
|
||||||
"""
|
"""Render the value as a HTML label."""
|
||||||
Render the value as a HTML label.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# If the key cannot be found, pass it back
|
# If the key cannot be found, pass it back
|
||||||
if key not in cls.options.keys():
|
if key not in cls.options.keys():
|
||||||
return key
|
return key
|
||||||
@ -31,10 +28,7 @@ class StatusCode:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
"""
|
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||||
Return the StatusCode options as a list of mapped key / value items
|
|
||||||
"""
|
|
||||||
|
|
||||||
codes = []
|
codes = []
|
||||||
|
|
||||||
for key in cls.options.keys():
|
for key in cls.options.keys():
|
||||||
@ -71,12 +65,12 @@ class StatusCode:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def label(cls, value):
|
def label(cls, value):
|
||||||
""" Return the status code label associated with the provided value """
|
"""Return the status code label associated with the provided value."""
|
||||||
return cls.options.get(value, value)
|
return cls.options.get(value, value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def value(cls, label):
|
def value(cls, label):
|
||||||
""" Return the value associated with the provided label """
|
"""Return the value associated with the provided label."""
|
||||||
for k in cls.options.keys():
|
for k in cls.options.keys():
|
||||||
if cls.options[k].lower() == label.lower():
|
if cls.options[k].lower() == label.lower():
|
||||||
return k
|
return k
|
||||||
@ -85,9 +79,7 @@ class StatusCode:
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderStatus(StatusCode):
|
class PurchaseOrderStatus(StatusCode):
|
||||||
"""
|
"""Defines a set of status codes for a PurchaseOrder."""
|
||||||
Defines a set of status codes for a PurchaseOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Order status codes
|
# Order status codes
|
||||||
PENDING = 10 # Order is pending (not yet placed)
|
PENDING = 10 # Order is pending (not yet placed)
|
||||||
@ -130,7 +122,7 @@ class PurchaseOrderStatus(StatusCode):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderStatus(StatusCode):
|
class SalesOrderStatus(StatusCode):
|
||||||
""" Defines a set of status codes for a SalesOrder """
|
"""Defines a set of status codes for a SalesOrder."""
|
||||||
|
|
||||||
PENDING = 10 # Order is pending
|
PENDING = 10 # Order is pending
|
||||||
SHIPPED = 20 # Order has been shipped to customer
|
SHIPPED = 20 # Order has been shipped to customer
|
||||||
|
@ -16,11 +16,10 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def schedule_task(taskname, **kwargs):
|
def schedule_task(taskname, **kwargs):
|
||||||
"""
|
"""Create a scheduled task.
|
||||||
Create a scheduled task.
|
|
||||||
If the task has already been scheduled, ignore!
|
If the task has already been scheduled, ignore!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If unspecified, repeat indefinitely
|
# If unspecified, repeat indefinitely
|
||||||
repeats = kwargs.pop('repeats', -1)
|
repeats = kwargs.pop('repeats', -1)
|
||||||
kwargs['repeats'] = repeats
|
kwargs['repeats'] = repeats
|
||||||
@ -52,7 +51,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def raise_warning(msg):
|
def raise_warning(msg):
|
||||||
"""Log and raise a warning"""
|
"""Log and raise a warning."""
|
||||||
logger.warning(msg)
|
logger.warning(msg)
|
||||||
|
|
||||||
# If testing is running raise a warning that can be asserted
|
# If testing is running raise a warning that can be asserted
|
||||||
@ -61,15 +60,11 @@ def raise_warning(msg):
|
|||||||
|
|
||||||
|
|
||||||
def offload_task(taskname, *args, force_sync=False, **kwargs):
|
def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||||
"""
|
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
|
||||||
Create an AsyncTask if workers are running.
|
|
||||||
This is different to a 'scheduled' task,
|
|
||||||
in that it only runs once!
|
|
||||||
|
|
||||||
If workers are not running or force_sync flag
|
If workers are not running or force_sync flag
|
||||||
is set then the task is ran synchronously.
|
is set then the task is ran synchronously.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
@ -129,14 +124,10 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
"""
|
"""Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running.
|
||||||
Simple task which runs at 5 minute intervals,
|
|
||||||
so we can determine that the background worker
|
|
||||||
is actually running.
|
|
||||||
|
|
||||||
(There is probably a less "hacky" way of achieving this)?
|
(There is probably a less "hacky" way of achieving this)?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
@ -156,11 +147,7 @@ def heartbeat():
|
|||||||
|
|
||||||
|
|
||||||
def delete_successful_tasks():
|
def delete_successful_tasks():
|
||||||
"""
|
"""Delete successful task logs which are more than a month old."""
|
||||||
Delete successful task logs
|
|
||||||
which are more than a month old.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
@ -179,10 +166,7 @@ def delete_successful_tasks():
|
|||||||
|
|
||||||
|
|
||||||
def delete_old_error_logs():
|
def delete_old_error_logs():
|
||||||
"""
|
"""Delete old error logs from the server."""
|
||||||
Delete old error logs from the server
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
|
|
||||||
@ -204,10 +188,7 @@ def delete_old_error_logs():
|
|||||||
|
|
||||||
|
|
||||||
def check_for_updates():
|
def check_for_updates():
|
||||||
"""
|
"""Check if there is an update for InvenTree."""
|
||||||
Check if there is an update for InvenTree
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import common.models
|
import common.models
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
@ -249,10 +230,7 @@ def check_for_updates():
|
|||||||
|
|
||||||
|
|
||||||
def update_exchange_rates():
|
def update_exchange_rates():
|
||||||
"""
|
"""Update currency exchange rates."""
|
||||||
Update currency exchange rates
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
|
|
||||||
@ -293,11 +271,7 @@ def update_exchange_rates():
|
|||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
"""
|
"""Send an email with the specified subject and body, to the specified recipients list."""
|
||||||
Send an email with the specified subject and body,
|
|
||||||
to the specified recipients list.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if type(recipients) == str:
|
if type(recipients) == str:
|
||||||
recipients = [recipients]
|
recipients = [recipients]
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
""" Low level tests for the InvenTree API """
|
"""Low level tests for the InvenTree API."""
|
||||||
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
|
||||||
@ -12,8 +12,7 @@ from users.models import RuleSet
|
|||||||
|
|
||||||
|
|
||||||
class HTMLAPITests(InvenTreeTestCase):
|
class HTMLAPITests(InvenTreeTestCase):
|
||||||
"""
|
"""Test that we can access the REST API endpoints via the HTML interface.
|
||||||
Test that we can access the REST API endpoints via the HTML interface.
|
|
||||||
|
|
||||||
History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer,
|
History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer,
|
||||||
which raised an AssertionError when using the HTML API interface,
|
which raised an AssertionError when using the HTML API interface,
|
||||||
@ -66,14 +65,13 @@ class HTMLAPITests(InvenTreeTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_not_found(self):
|
def test_not_found(self):
|
||||||
"""Test that the NotFoundView is working"""
|
"""Test that the NotFoundView is working."""
|
||||||
|
|
||||||
response = self.client.get('/api/anc')
|
response = self.client.get('/api/anc')
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
class APITests(InvenTreeAPITestCase):
|
class APITests(InvenTreeAPITestCase):
|
||||||
""" Tests for the InvenTree API """
|
"""Tests for the InvenTree API."""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'location',
|
'location',
|
||||||
@ -125,10 +123,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIsNotNone(self.token)
|
self.assertIsNotNone(self.token)
|
||||||
|
|
||||||
def test_info_view(self):
|
def test_info_view(self):
|
||||||
"""
|
"""Test that we can read the 'info-view' endpoint."""
|
||||||
Test that we can read the 'info-view' endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-inventree-info')
|
url = reverse('api-inventree-info')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
@ -141,12 +136,10 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual('InvenTree', data['server'])
|
self.assertEqual('InvenTree', data['server'])
|
||||||
|
|
||||||
def test_role_view(self):
|
def test_role_view(self):
|
||||||
"""
|
"""Test that we can access the 'roles' view for the logged in user.
|
||||||
Test that we can access the 'roles' view for the logged in user.
|
|
||||||
|
|
||||||
Also tests that it is *not* accessible if the client is not logged in.
|
Also tests that it is *not* accessible if the client is not logged in.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = reverse('api-user-roles')
|
url = reverse('api-user-roles')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
@ -182,10 +175,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertNotIn('delete', roles[rule])
|
self.assertNotIn('delete', roles[rule])
|
||||||
|
|
||||||
def test_with_superuser(self):
|
def test_with_superuser(self):
|
||||||
"""
|
"""Superuser should have *all* roles assigned."""
|
||||||
Superuser should have *all* roles assigned
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.user.is_superuser = True
|
self.user.is_superuser = True
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
@ -202,10 +192,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn(perm, roles[rule])
|
self.assertIn(perm, roles[rule])
|
||||||
|
|
||||||
def test_with_roles(self):
|
def test_with_roles(self):
|
||||||
"""
|
"""Assign some roles to the user."""
|
||||||
Assign some roles to the user
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.basicAuth()
|
self.basicAuth()
|
||||||
response = self.get(reverse('api-user-roles'))
|
response = self.get(reverse('api-user-roles'))
|
||||||
|
|
||||||
@ -220,10 +207,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('change', roles['build'])
|
self.assertIn('change', roles['build'])
|
||||||
|
|
||||||
def test_list_endpoint_actions(self):
|
def test_list_endpoint_actions(self):
|
||||||
"""
|
"""Tests for the OPTIONS method for API endpoints."""
|
||||||
Tests for the OPTIONS method for API endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.basicAuth()
|
self.basicAuth()
|
||||||
|
|
||||||
# Without any 'part' permissions, we should not see any available actions
|
# Without any 'part' permissions, we should not see any available actions
|
||||||
@ -252,10 +236,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('GET', actions)
|
self.assertIn('GET', actions)
|
||||||
|
|
||||||
def test_detail_endpoint_actions(self):
|
def test_detail_endpoint_actions(self):
|
||||||
"""
|
"""Tests for detail API endpoint actions."""
|
||||||
Tests for detail API endpoint actions
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.basicAuth()
|
self.basicAuth()
|
||||||
|
|
||||||
url = reverse('api-part-detail', kwargs={'pk': 1})
|
url = reverse('api-part-detail', kwargs={'pk': 1})
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Tests for middleware functions"""
|
"""Tests for middleware functions."""
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@ -6,7 +6,7 @@ from InvenTree.helpers import InvenTreeTestCase
|
|||||||
|
|
||||||
|
|
||||||
class MiddlewareTests(InvenTreeTestCase):
|
class MiddlewareTests(InvenTreeTestCase):
|
||||||
"""Test for middleware functions"""
|
"""Test for middleware functions."""
|
||||||
|
|
||||||
def check_path(self, url, code=200, **kwargs):
|
def check_path(self, url, code=200, **kwargs):
|
||||||
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
|
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
|
||||||
@ -14,8 +14,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def test_AuthRequiredMiddleware(self):
|
def test_AuthRequiredMiddleware(self):
|
||||||
"""Test the auth middleware"""
|
"""Test the auth middleware."""
|
||||||
|
|
||||||
# test that /api/ routes go through
|
# test that /api/ routes go through
|
||||||
self.check_path(reverse('api-inventree-info'))
|
self.check_path(reverse('api-inventree-info'))
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
|||||||
self.check_path(reverse('settings.js'), 401)
|
self.check_path(reverse('settings.js'), 401)
|
||||||
|
|
||||||
def test_token_auth(self):
|
def test_token_auth(self):
|
||||||
"""Test auth with token auth"""
|
"""Test auth with token auth."""
|
||||||
# get token
|
# get token
|
||||||
response = self.client.get(reverse('api-token'), format='json', data={})
|
response = self.client.get(reverse('api-token'), format='json', data={})
|
||||||
token = response.data['token']
|
token = response.data['token']
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Unit tests for task management."""
|
||||||
Unit tests for task management
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@ -18,19 +16,14 @@ threshold_low = threshold - timedelta(days=1)
|
|||||||
|
|
||||||
|
|
||||||
class ScheduledTaskTests(TestCase):
|
class ScheduledTaskTests(TestCase):
|
||||||
"""
|
"""Unit tests for scheduled tasks."""
|
||||||
Unit tests for scheduled tasks
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_tasks(self, name):
|
def get_tasks(self, name):
|
||||||
|
|
||||||
return Schedule.objects.filter(func=name)
|
return Schedule.objects.filter(func=name)
|
||||||
|
|
||||||
def test_add_task(self):
|
def test_add_task(self):
|
||||||
"""
|
"""Ensure that duplicate tasks cannot be added."""
|
||||||
Ensure that duplicate tasks cannot be added.
|
|
||||||
"""
|
|
||||||
|
|
||||||
task = 'InvenTree.tasks.heartbeat'
|
task = 'InvenTree.tasks.heartbeat'
|
||||||
|
|
||||||
self.assertEqual(self.get_tasks(task).count(), 0)
|
self.assertEqual(self.get_tasks(task).count(), 0)
|
||||||
@ -53,16 +46,15 @@ class ScheduledTaskTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
def get_result():
|
def get_result():
|
||||||
"""Demo function for test_offloading"""
|
"""Demo function for test_offloading."""
|
||||||
return 'abc'
|
return 'abc'
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeTaskTests(TestCase):
|
class InvenTreeTaskTests(TestCase):
|
||||||
"""Unit tests for tasks"""
|
"""Unit tests for tasks."""
|
||||||
|
|
||||||
def test_offloading(self):
|
def test_offloading(self):
|
||||||
"""Test task offloading"""
|
"""Test task offloading."""
|
||||||
|
|
||||||
# Run with function ref
|
# Run with function ref
|
||||||
InvenTree.tasks.offload_task(get_result)
|
InvenTree.tasks.offload_task(get_result)
|
||||||
|
|
||||||
@ -83,11 +75,11 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')
|
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')
|
||||||
|
|
||||||
def test_task_hearbeat(self):
|
def test_task_hearbeat(self):
|
||||||
"""Test the task heartbeat"""
|
"""Test the task heartbeat."""
|
||||||
InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat)
|
InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat)
|
||||||
|
|
||||||
def test_task_delete_successful_tasks(self):
|
def test_task_delete_successful_tasks(self):
|
||||||
"""Test the task delete_successful_tasks"""
|
"""Test the task delete_successful_tasks."""
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
|
|
||||||
Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low)
|
Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low)
|
||||||
@ -96,8 +88,7 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
|
|
||||||
def test_task_delete_old_error_logs(self):
|
def test_task_delete_old_error_logs(self):
|
||||||
"""Test the task delete_old_error_logs"""
|
"""Test the task delete_old_error_logs."""
|
||||||
|
|
||||||
# Create error
|
# Create error
|
||||||
error_obj = Error.objects.create()
|
error_obj = Error.objects.create()
|
||||||
error_obj.when = threshold_low
|
error_obj.when = threshold_low
|
||||||
@ -115,7 +106,7 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
self.assertEqual(len(errors), 0)
|
self.assertEqual(len(errors), 0)
|
||||||
|
|
||||||
def test_task_check_for_updates(self):
|
def test_task_check_for_updates(self):
|
||||||
"""Test the task check_for_updates"""
|
"""Test the task check_for_updates."""
|
||||||
# Check that setting should be empty
|
# Check that setting should be empty
|
||||||
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Validate that all URLs specified in template files are correct."""
|
||||||
Validate that all URLs specified in template files are correct.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -35,11 +33,7 @@ class URLTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def find_files(self, suffix):
|
def find_files(self, suffix):
|
||||||
"""
|
"""Search for all files in the template directories, which can have URLs rendered."""
|
||||||
Search for all files in the template directories,
|
|
||||||
which can have URLs rendered
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_dirs = [
|
template_dirs = [
|
||||||
('build', 'templates'),
|
('build', 'templates'),
|
||||||
('common', 'templates'),
|
('common', 'templates'),
|
||||||
@ -71,10 +65,7 @@ class URLTest(TestCase):
|
|||||||
return template_files
|
return template_files
|
||||||
|
|
||||||
def find_urls(self, input_file):
|
def find_urls(self, input_file):
|
||||||
"""
|
"""Search for all instances of {% url %} in supplied template file."""
|
||||||
Search for all instances of {% url %} in supplied template file
|
|
||||||
"""
|
|
||||||
|
|
||||||
urls = []
|
urls = []
|
||||||
|
|
||||||
pattern = "{% url ['\"]([^'\"]+)['\"]([^%]*)%}"
|
pattern = "{% url ['\"]([^'\"]+)['\"]([^%]*)%}"
|
||||||
@ -100,10 +91,7 @@ class URLTest(TestCase):
|
|||||||
return urls
|
return urls
|
||||||
|
|
||||||
def reverse_url(self, url_pair):
|
def reverse_url(self, url_pair):
|
||||||
"""
|
"""Perform lookup on the URL."""
|
||||||
Perform lookup on the URL
|
|
||||||
"""
|
|
||||||
|
|
||||||
url, pk = url_pair
|
url, pk = url_pair
|
||||||
|
|
||||||
# Ignore "renaming"
|
# Ignore "renaming"
|
||||||
@ -125,10 +113,7 @@ class URLTest(TestCase):
|
|||||||
reverse(url)
|
reverse(url)
|
||||||
|
|
||||||
def check_file(self, f):
|
def check_file(self, f):
|
||||||
"""
|
"""Run URL checks for the provided file."""
|
||||||
Run URL checks for the provided file
|
|
||||||
"""
|
|
||||||
|
|
||||||
urls = self.find_urls(f)
|
urls = self.find_urls(f)
|
||||||
|
|
||||||
for url in urls:
|
for url in urls:
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Unit tests for the main web views."""
|
||||||
Unit tests for the main web views
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -11,33 +9,26 @@ from InvenTree.helpers import InvenTreeTestCase
|
|||||||
|
|
||||||
|
|
||||||
class ViewTests(InvenTreeTestCase):
|
class ViewTests(InvenTreeTestCase):
|
||||||
""" Tests for various top-level views """
|
"""Tests for various top-level views."""
|
||||||
|
|
||||||
username = 'test_user'
|
username = 'test_user'
|
||||||
password = 'test_pass'
|
password = 'test_pass'
|
||||||
|
|
||||||
def test_api_doc(self):
|
def test_api_doc(self):
|
||||||
""" Test that the api-doc view works """
|
"""Test that the api-doc view works."""
|
||||||
|
|
||||||
api_url = os.path.join(reverse('index'), 'api-doc') + '/'
|
api_url = os.path.join(reverse('index'), 'api-doc') + '/'
|
||||||
|
|
||||||
response = self.client.get(api_url)
|
response = self.client.get(api_url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_index_redirect(self):
|
def test_index_redirect(self):
|
||||||
"""
|
"""Top-level URL should redirect to "index" page."""
|
||||||
top-level URL should redirect to "index" page
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.get("/")
|
response = self.client.get("/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def get_index_page(self):
|
def get_index_page(self):
|
||||||
"""
|
"""Retrieve the index page (used for subsequent unit tests)"""
|
||||||
Retrieve the index page (used for subsequent unit tests)
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.client.get("/index/")
|
response = self.client.get("/index/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@ -45,10 +36,7 @@ class ViewTests(InvenTreeTestCase):
|
|||||||
return str(response.content.decode())
|
return str(response.content.decode())
|
||||||
|
|
||||||
def test_panels(self):
|
def test_panels(self):
|
||||||
"""
|
"""Test that the required 'panels' are present."""
|
||||||
Test that the required 'panels' are present
|
|
||||||
"""
|
|
||||||
|
|
||||||
content = self.get_index_page()
|
content = self.get_index_page()
|
||||||
|
|
||||||
self.assertIn("<div id='detail-panels'>", content)
|
self.assertIn("<div id='detail-panels'>", content)
|
||||||
@ -56,10 +44,7 @@ class ViewTests(InvenTreeTestCase):
|
|||||||
# TODO: In future, run the javascript and ensure that the panels get created!
|
# TODO: In future, run the javascript and ensure that the panels get created!
|
||||||
|
|
||||||
def test_js_load(self):
|
def test_js_load(self):
|
||||||
"""
|
"""Test that the required javascript files are loaded correctly."""
|
||||||
Test that the required javascript files are loaded correctly
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Change this number as more javascript files are added to the index page
|
# Change this number as more javascript files are added to the index page
|
||||||
N_SCRIPT_FILES = 40
|
N_SCRIPT_FILES = 40
|
||||||
|
|
||||||
|
@ -24,20 +24,17 @@ from .validators import validate_overage, validate_part_name
|
|||||||
|
|
||||||
|
|
||||||
class ValidatorTest(TestCase):
|
class ValidatorTest(TestCase):
|
||||||
|
"""Simple tests for custom field validators."""
|
||||||
""" Simple tests for custom field validators """
|
|
||||||
|
|
||||||
def test_part_name(self):
|
def test_part_name(self):
|
||||||
""" Test part name validator """
|
"""Test part name validator."""
|
||||||
|
|
||||||
validate_part_name('hello world')
|
validate_part_name('hello world')
|
||||||
|
|
||||||
with self.assertRaises(django_exceptions.ValidationError):
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
validate_part_name('This | name is not } valid')
|
validate_part_name('This | name is not } valid')
|
||||||
|
|
||||||
def test_overage(self):
|
def test_overage(self):
|
||||||
""" Test overage validator """
|
"""Test overage validator."""
|
||||||
|
|
||||||
validate_overage("100%")
|
validate_overage("100%")
|
||||||
validate_overage("10")
|
validate_overage("10")
|
||||||
validate_overage("45.2 %")
|
validate_overage("45.2 %")
|
||||||
@ -59,11 +56,10 @@ class ValidatorTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestHelpers(TestCase):
|
class TestHelpers(TestCase):
|
||||||
""" Tests for InvenTree helper functions """
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|
||||||
def test_image_url(self):
|
def test_image_url(self):
|
||||||
""" Test if a filename looks like an image """
|
"""Test if a filename looks like an image."""
|
||||||
|
|
||||||
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
|
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
|
||||||
self.assertTrue(helpers.TestIfImageURL(name))
|
self.assertTrue(helpers.TestIfImageURL(name))
|
||||||
|
|
||||||
@ -71,8 +67,7 @@ class TestHelpers(TestCase):
|
|||||||
self.assertFalse(helpers.TestIfImageURL(name))
|
self.assertFalse(helpers.TestIfImageURL(name))
|
||||||
|
|
||||||
def test_str2bool(self):
|
def test_str2bool(self):
|
||||||
""" Test string to boolean conversion """
|
"""Test string to boolean conversion."""
|
||||||
|
|
||||||
for s in ['yes', 'Y', 'ok', '1', 'OK', 'Ok', 'tRuE', 'oN']:
|
for s in ['yes', 'Y', 'ok', '1', 'OK', 'Ok', 'tRuE', 'oN']:
|
||||||
self.assertTrue(helpers.str2bool(s))
|
self.assertTrue(helpers.str2bool(s))
|
||||||
self.assertFalse(helpers.str2bool(s, test=False))
|
self.assertFalse(helpers.str2bool(s, test=False))
|
||||||
@ -110,7 +105,7 @@ class TestHelpers(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestQuoteWrap(TestCase):
|
class TestQuoteWrap(TestCase):
|
||||||
""" Tests for string wrapping """
|
"""Tests for string wrapping."""
|
||||||
|
|
||||||
def test_single(self):
|
def test_single(self):
|
||||||
|
|
||||||
@ -121,8 +116,7 @@ class TestQuoteWrap(TestCase):
|
|||||||
class TestIncrement(TestCase):
|
class TestIncrement(TestCase):
|
||||||
|
|
||||||
def tests(self):
|
def tests(self):
|
||||||
""" Test 'intelligent' incrementing function """
|
"""Test 'intelligent' incrementing function."""
|
||||||
|
|
||||||
tests = [
|
tests = [
|
||||||
("", ""),
|
("", ""),
|
||||||
(1, "2"),
|
(1, "2"),
|
||||||
@ -142,7 +136,7 @@ class TestIncrement(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMakeBarcode(TestCase):
|
class TestMakeBarcode(TestCase):
|
||||||
""" Tests for barcode string creation """
|
"""Tests for barcode string creation."""
|
||||||
|
|
||||||
def test_barcode_extended(self):
|
def test_barcode_extended(self):
|
||||||
|
|
||||||
@ -185,7 +179,7 @@ class TestDownloadFile(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMPTT(TestCase):
|
class TestMPTT(TestCase):
|
||||||
""" Tests for the MPTT tree models """
|
"""Tests for the MPTT tree models."""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'location',
|
'location',
|
||||||
@ -197,8 +191,7 @@ class TestMPTT(TestCase):
|
|||||||
StockLocation.objects.rebuild()
|
StockLocation.objects.rebuild()
|
||||||
|
|
||||||
def test_self_as_parent(self):
|
def test_self_as_parent(self):
|
||||||
""" Test that we cannot set self as parent """
|
"""Test that we cannot set self as parent."""
|
||||||
|
|
||||||
loc = StockLocation.objects.get(pk=4)
|
loc = StockLocation.objects.get(pk=4)
|
||||||
loc.parent = loc
|
loc.parent = loc
|
||||||
|
|
||||||
@ -206,8 +199,7 @@ class TestMPTT(TestCase):
|
|||||||
loc.save()
|
loc.save()
|
||||||
|
|
||||||
def test_child_as_parent(self):
|
def test_child_as_parent(self):
|
||||||
""" Test that we cannot set a child as parent """
|
"""Test that we cannot set a child as parent."""
|
||||||
|
|
||||||
parent = StockLocation.objects.get(pk=4)
|
parent = StockLocation.objects.get(pk=4)
|
||||||
child = StockLocation.objects.get(pk=5)
|
child = StockLocation.objects.get(pk=5)
|
||||||
|
|
||||||
@ -217,8 +209,7 @@ class TestMPTT(TestCase):
|
|||||||
parent.save()
|
parent.save()
|
||||||
|
|
||||||
def test_move(self):
|
def test_move(self):
|
||||||
""" Move an item to a different tree """
|
"""Move an item to a different tree."""
|
||||||
|
|
||||||
drawer = StockLocation.objects.get(name='Drawer_1')
|
drawer = StockLocation.objects.get(name='Drawer_1')
|
||||||
|
|
||||||
# Record the tree ID
|
# Record the tree ID
|
||||||
@ -233,7 +224,7 @@ class TestMPTT(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestSerialNumberExtraction(TestCase):
|
class TestSerialNumberExtraction(TestCase):
|
||||||
""" Tests for serial number extraction code """
|
"""Tests for serial number extraction code."""
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
|
|
||||||
@ -352,9 +343,7 @@ class TestSerialNumberExtraction(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestVersionNumber(TestCase):
|
class TestVersionNumber(TestCase):
|
||||||
"""
|
"""Unit tests for version number functions."""
|
||||||
Unit tests for version number functions
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_tuple(self):
|
def test_tuple(self):
|
||||||
|
|
||||||
@ -366,10 +355,7 @@ class TestVersionNumber(TestCase):
|
|||||||
self.assertTrue(s in version.inventreeVersion())
|
self.assertTrue(s in version.inventreeVersion())
|
||||||
|
|
||||||
def test_comparison(self):
|
def test_comparison(self):
|
||||||
"""
|
"""Test direct comparison of version numbers."""
|
||||||
Test direct comparison of version numbers
|
|
||||||
"""
|
|
||||||
|
|
||||||
v_a = version.inventreeVersionTuple('1.2.0')
|
v_a = version.inventreeVersionTuple('1.2.0')
|
||||||
v_b = version.inventreeVersionTuple('1.2.3')
|
v_b = version.inventreeVersionTuple('1.2.3')
|
||||||
v_c = version.inventreeVersionTuple('1.2.4')
|
v_c = version.inventreeVersionTuple('1.2.4')
|
||||||
@ -382,9 +368,7 @@ class TestVersionNumber(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CurrencyTests(TestCase):
|
class CurrencyTests(TestCase):
|
||||||
"""
|
"""Unit tests for currency / exchange rate functionality."""
|
||||||
Unit tests for currency / exchange rate functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_rates(self):
|
def test_rates(self):
|
||||||
|
|
||||||
@ -435,12 +419,10 @@ class CurrencyTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestStatus(TestCase):
|
class TestStatus(TestCase):
|
||||||
"""
|
"""Unit tests for status functions."""
|
||||||
Unit tests for status functions
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_check_system_healt(self):
|
def test_check_system_healt(self):
|
||||||
"""test that the system health check is false in testing -> background worker not running"""
|
"""Test that the system health check is false in testing -> background worker not running."""
|
||||||
self.assertEqual(status.check_system_health(), False)
|
self.assertEqual(status.check_system_health(), False)
|
||||||
|
|
||||||
def test_TestMode(self):
|
def test_TestMode(self):
|
||||||
@ -451,14 +433,12 @@ class TestStatus(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestSettings(helpers.InvenTreeTestCase):
|
class TestSettings(helpers.InvenTreeTestCase):
|
||||||
"""
|
"""Unit tests for settings."""
|
||||||
Unit tests for settings
|
|
||||||
"""
|
|
||||||
|
|
||||||
superuser = True
|
superuser = True
|
||||||
|
|
||||||
def in_env_context(self, envs={}):
|
def in_env_context(self, envs={}):
|
||||||
"""Patch the env to include the given dict"""
|
"""Patch the env to include the given dict."""
|
||||||
return mock.patch.dict(os.environ, envs)
|
return mock.patch.dict(os.environ, envs)
|
||||||
|
|
||||||
def run_reload(self, envs={}):
|
def run_reload(self, envs={}):
|
||||||
@ -513,7 +493,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
|||||||
settings.TESTING_ENV = False
|
settings.TESTING_ENV = False
|
||||||
|
|
||||||
def test_initial_install(self):
|
def test_initial_install(self):
|
||||||
"""Test if install of plugins on startup works"""
|
"""Test if install of plugins on startup works."""
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
# Check an install run
|
# Check an install run
|
||||||
@ -567,9 +547,7 @@ class TestSettings(helpers.InvenTreeTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestInstanceName(helpers.InvenTreeTestCase):
|
class TestInstanceName(helpers.InvenTreeTestCase):
|
||||||
"""
|
"""Unit tests for instance name."""
|
||||||
Unit tests for instance name
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_instance_name(self):
|
def test_instance_name(self):
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Top-level URL lookup for InvenTree application.
|
||||||
Top-level URL lookup for InvenTree application.
|
|
||||||
|
|
||||||
Passes URL lookup downstream to each app as required.
|
Passes URL lookup downstream to each app as required.
|
||||||
"""
|
"""
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Custom field validators for InvenTree."""
|
||||||
Custom field validators for InvenTree
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@ -15,20 +13,18 @@ import common.models
|
|||||||
|
|
||||||
|
|
||||||
def validate_currency_code(code):
|
def validate_currency_code(code):
|
||||||
"""
|
"""Check that a given code is a valid currency code."""
|
||||||
Check that a given code is a valid currency code.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if code not in CURRENCIES:
|
if code not in CURRENCIES:
|
||||||
raise ValidationError(_('Not a valid currency code'))
|
raise ValidationError(_('Not a valid currency code'))
|
||||||
|
|
||||||
|
|
||||||
def allowable_url_schemes():
|
def allowable_url_schemes():
|
||||||
""" Return the list of allowable URL schemes.
|
"""Return the list of allowable URL schemes.
|
||||||
|
|
||||||
In addition to the default schemes allowed by Django,
|
In addition to the default schemes allowed by Django,
|
||||||
the install configuration file (config.yaml) can specify
|
the install configuration file (config.yaml) can specify
|
||||||
extra schemas """
|
extra schemas
|
||||||
|
"""
|
||||||
# Default schemes
|
# Default schemes
|
||||||
schemes = ['http', 'https', 'ftp', 'ftps']
|
schemes = ['http', 'https', 'ftp', 'ftps']
|
||||||
|
|
||||||
@ -42,9 +38,7 @@ def allowable_url_schemes():
|
|||||||
|
|
||||||
|
|
||||||
def validate_part_name(value):
|
def validate_part_name(value):
|
||||||
""" Prevent some illegal characters in part names.
|
"""Prevent some illegal characters in part names."""
|
||||||
"""
|
|
||||||
|
|
||||||
for c in ['|', '#', '$', '{', '}']:
|
for c in ['|', '#', '$', '{', '}']:
|
||||||
if c in str(value):
|
if c in str(value):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@ -53,8 +47,7 @@ def validate_part_name(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_part_ipn(value):
|
def validate_part_ipn(value):
|
||||||
""" Validate the Part IPN against regex rule """
|
"""Validate the Part IPN against regex rule."""
|
||||||
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
@ -65,10 +58,7 @@ def validate_part_ipn(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_build_order_reference(value):
|
def validate_build_order_reference(value):
|
||||||
"""
|
"""Validate the 'reference' field of a BuildOrder."""
|
||||||
Validate the 'reference' field of a BuildOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('BUILDORDER_REFERENCE_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('BUILDORDER_REFERENCE_REGEX')
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
@ -79,10 +69,7 @@ def validate_build_order_reference(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_purchase_order_reference(value):
|
def validate_purchase_order_reference(value):
|
||||||
"""
|
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||||
Validate the 'reference' field of a PurchaseOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
@ -93,10 +80,7 @@ def validate_purchase_order_reference(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_sales_order_reference(value):
|
def validate_sales_order_reference(value):
|
||||||
"""
|
"""Validate the 'reference' field of a SalesOrder."""
|
||||||
Validate the 'reference' field of a SalesOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_REGEX')
|
||||||
|
|
||||||
if pattern:
|
if pattern:
|
||||||
@ -107,16 +91,14 @@ def validate_sales_order_reference(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_tree_name(value):
|
def validate_tree_name(value):
|
||||||
""" Prevent illegal characters in tree item names """
|
"""Prevent illegal characters in tree item names."""
|
||||||
|
|
||||||
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"":
|
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"":
|
||||||
if c in str(value):
|
if c in str(value):
|
||||||
raise ValidationError(_('Illegal character in name ({x})'.format(x=c)))
|
raise ValidationError(_('Illegal character in name ({x})'.format(x=c)))
|
||||||
|
|
||||||
|
|
||||||
def validate_overage(value):
|
def validate_overage(value):
|
||||||
"""
|
"""Validate that a BOM overage string is properly formatted.
|
||||||
Validate that a BOM overage string is properly formatted.
|
|
||||||
|
|
||||||
An overage string can look like:
|
An overage string can look like:
|
||||||
|
|
||||||
@ -124,7 +106,6 @@ def validate_overage(value):
|
|||||||
- A decimal number ('0.123')
|
- A decimal number ('0.123')
|
||||||
- A percentage ('5%' / '10 %')
|
- A percentage ('5%' / '10 %')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str(value).lower().strip()
|
value = str(value).lower().strip()
|
||||||
|
|
||||||
# First look for a simple numerical value
|
# First look for a simple numerical value
|
||||||
@ -162,11 +143,10 @@ def validate_overage(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_part_name_format(self):
|
def validate_part_name_format(self):
|
||||||
"""
|
"""Validate part name format.
|
||||||
Validate part name format.
|
|
||||||
Make sure that each template container has a field of Part Model
|
Make sure that each template container has a field of Part Model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
jinja_template_regex = re.compile('{{.*?}}')
|
jinja_template_regex = re.compile('{{.*?}}')
|
||||||
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
|
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
|
||||||
for jinja_template in jinja_template_regex.findall(str(self)):
|
for jinja_template in jinja_template_regex.findall(str(self)):
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""Version information for InvenTree.
|
||||||
Version information for InvenTree.
|
|
||||||
Provides information on the current InvenTree version
|
Provides information on the current InvenTree version
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -16,12 +16,12 @@ INVENTREE_SW_VERSION = "0.8.0 dev"
|
|||||||
|
|
||||||
|
|
||||||
def inventreeInstanceName():
|
def inventreeInstanceName():
|
||||||
""" Returns the InstanceName settings for the current database """
|
"""Returns the InstanceName settings for the current database."""
|
||||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||||
|
|
||||||
|
|
||||||
def inventreeInstanceTitle():
|
def inventreeInstanceTitle():
|
||||||
""" Returns the InstanceTitle for the current database """
|
"""Returns the InstanceTitle for the current database."""
|
||||||
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
||||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||||
else:
|
else:
|
||||||
@ -29,13 +29,12 @@ def inventreeInstanceTitle():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeVersion():
|
def inventreeVersion():
|
||||||
""" Returns the InvenTree version string """
|
"""Returns the InvenTree version string."""
|
||||||
return INVENTREE_SW_VERSION.lower().strip()
|
return INVENTREE_SW_VERSION.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
def inventreeVersionTuple(version=None):
|
def inventreeVersionTuple(version=None):
|
||||||
""" Return the InvenTree version string as (maj, min, sub) tuple """
|
"""Return the InvenTree version string as (maj, min, sub) tuple."""
|
||||||
|
|
||||||
if version is None:
|
if version is None:
|
||||||
version = INVENTREE_SW_VERSION
|
version = INVENTREE_SW_VERSION
|
||||||
|
|
||||||
@ -45,21 +44,16 @@ def inventreeVersionTuple(version=None):
|
|||||||
|
|
||||||
|
|
||||||
def isInvenTreeDevelopmentVersion():
|
def isInvenTreeDevelopmentVersion():
|
||||||
"""
|
"""Return True if current InvenTree version is a "development" version."""
|
||||||
Return True if current InvenTree version is a "development" version
|
|
||||||
"""
|
|
||||||
return inventreeVersion().endswith('dev')
|
return inventreeVersion().endswith('dev')
|
||||||
|
|
||||||
|
|
||||||
def inventreeDocsVersion():
|
def inventreeDocsVersion():
|
||||||
"""
|
"""Return the version string matching the latest documentation.
|
||||||
Return the version string matching the latest documentation.
|
|
||||||
|
|
||||||
Development -> "latest"
|
Development -> "latest"
|
||||||
Release -> "major.minor.sub" e.g. "0.5.2"
|
Release -> "major.minor.sub" e.g. "0.5.2"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if isInvenTreeDevelopmentVersion():
|
if isInvenTreeDevelopmentVersion():
|
||||||
return "latest"
|
return "latest"
|
||||||
else:
|
else:
|
||||||
@ -67,13 +61,10 @@ def inventreeDocsVersion():
|
|||||||
|
|
||||||
|
|
||||||
def isInvenTreeUpToDate():
|
def isInvenTreeUpToDate():
|
||||||
"""
|
"""Test if the InvenTree instance is "up to date" with the latest version.
|
||||||
Test if the InvenTree instance is "up to date" with the latest version.
|
|
||||||
|
|
||||||
A background task periodically queries GitHub for latest version,
|
A background task periodically queries GitHub for latest version, and stores it to the database as INVENTREE_LATEST_VERSION
|
||||||
and stores it to the database as INVENTREE_LATEST_VERSION
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||||
|
|
||||||
# No record for "latest" version - we must assume we are up to date!
|
# No record for "latest" version - we must assume we are up to date!
|
||||||
@ -92,13 +83,12 @@ def inventreeApiVersion():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeDjangoVersion():
|
def inventreeDjangoVersion():
|
||||||
""" Return the version of Django library """
|
"""Return the version of Django library."""
|
||||||
return django.get_version()
|
return django.get_version()
|
||||||
|
|
||||||
|
|
||||||
def inventreeCommitHash():
|
def inventreeCommitHash():
|
||||||
""" Returns the git commit hash for the running codebase """
|
"""Returns the git commit hash for the running codebase."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||||
except: # pragma: no cover
|
except: # pragma: no cover
|
||||||
@ -106,8 +96,7 @@ def inventreeCommitHash():
|
|||||||
|
|
||||||
|
|
||||||
def inventreeCommitDate():
|
def inventreeCommitDate():
|
||||||
""" Returns the git commit date for the running codebase """
|
"""Returns the git commit date for the running codebase."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||||
return d.split(' ')[0]
|
return d.split(' ')[0]
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Various Views which provide extra functionality over base Django Views.
|
||||||
Various Views which provide extra functionality over base Django Views.
|
|
||||||
|
|
||||||
In particular these views provide base functionality for rendering Django forms
|
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).
|
||||||
@ -41,12 +40,10 @@ from .helpers import str2bool
|
|||||||
|
|
||||||
|
|
||||||
def auth_request(request):
|
def auth_request(request):
|
||||||
"""
|
"""Simple 'auth' endpoint used to determine if the user is authenticated.
|
||||||
Simple 'auth' endpoint used to determine if the user is authenticated.
|
|
||||||
Useful for (for example) redirecting authentication requests through
|
|
||||||
django's permission framework.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
Useful for (for example) redirecting authentication requests through django's permission framework.
|
||||||
|
"""
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
else:
|
else:
|
||||||
@ -54,8 +51,7 @@ def auth_request(request):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeRoleMixin(PermissionRequiredMixin):
|
class InvenTreeRoleMixin(PermissionRequiredMixin):
|
||||||
"""
|
"""Permission class based on user roles, not user 'permissions'.
|
||||||
Permission class based on user roles, not user 'permissions'.
|
|
||||||
|
|
||||||
There are a number of ways that the permissions can be specified for a view:
|
There are a number of ways that the permissions can be specified for a view:
|
||||||
|
|
||||||
@ -97,10 +93,7 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
|||||||
role_required = None
|
role_required = None
|
||||||
|
|
||||||
def has_permission(self):
|
def has_permission(self):
|
||||||
"""
|
"""Determine if the current user has specified permissions."""
|
||||||
Determine if the current user has specified permissions
|
|
||||||
"""
|
|
||||||
|
|
||||||
roles_required = []
|
roles_required = []
|
||||||
|
|
||||||
if type(self.role_required) is str:
|
if type(self.role_required) is str:
|
||||||
@ -163,8 +156,7 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_permission_class(self):
|
def get_permission_class(self):
|
||||||
"""
|
"""Return the 'permission_class' required for the current View.
|
||||||
Return the 'permission_class' required for the current View.
|
|
||||||
|
|
||||||
Must be one of:
|
Must be one of:
|
||||||
|
|
||||||
@ -177,7 +169,6 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
|||||||
'permission_class' attribute,
|
'permission_class' attribute,
|
||||||
or it can be "guessed" by looking at the type of class
|
or it can be "guessed" by looking at the type of class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perm = getattr(self, 'permission_class', None)
|
perm = getattr(self, 'permission_class', None)
|
||||||
|
|
||||||
# Permission is specified by the class itself
|
# Permission is specified by the class itself
|
||||||
@ -204,13 +195,10 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
|||||||
|
|
||||||
|
|
||||||
class AjaxMixin(InvenTreeRoleMixin):
|
class AjaxMixin(InvenTreeRoleMixin):
|
||||||
""" AjaxMixin provides basic functionality for rendering a Django form to JSON.
|
"""AjaxMixin provides basic functionality for rendering a Django form to JSON. Handles jsonResponse rendering, and adds extra data for the modal forms to process on the client side.
|
||||||
Handles jsonResponse rendering, and adds extra data for the modal forms to process
|
|
||||||
on the client side.
|
|
||||||
|
|
||||||
Any view which inherits the AjaxMixin will need
|
Any view which inherits the AjaxMixin will need
|
||||||
correct permissions set using the 'role_required' attribute
|
correct permissions set using the 'role_required' attribute
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# By default, allow *any* role
|
# By default, allow *any* role
|
||||||
@ -223,11 +211,11 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
ajax_form_title = ''
|
ajax_form_title = ''
|
||||||
|
|
||||||
def get_form_title(self):
|
def get_form_title(self):
|
||||||
""" Default implementation - return the ajax_form_title variable """
|
"""Default implementation - return the ajax_form_title variable"""
|
||||||
return self.ajax_form_title
|
return self.ajax_form_title
|
||||||
|
|
||||||
def get_param(self, name, method='GET'):
|
def get_param(self, name, method='GET'):
|
||||||
""" Get a request query parameter value from URL e.g. ?part=3
|
"""Get a request query parameter value from URL e.g. ?part=3.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Variable name e.g. 'part'
|
name: Variable name e.g. 'part'
|
||||||
@ -236,14 +224,13 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
Returns:
|
Returns:
|
||||||
Value of the supplier parameter or None if parameter is not available
|
Value of the supplier parameter or None if parameter is not available
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if method == 'POST':
|
if method == 'POST':
|
||||||
return self.request.POST.get(name, None)
|
return self.request.POST.get(name, None)
|
||||||
else:
|
else:
|
||||||
return self.request.GET.get(name, None)
|
return self.request.GET.get(name, None)
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
""" Get extra context data (default implementation is empty dict)
|
"""Get extra context data (default implementation is empty dict)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict object (empty)
|
dict object (empty)
|
||||||
@ -251,20 +238,18 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def validate(self, obj, form, **kwargs):
|
def validate(self, obj, form, **kwargs):
|
||||||
"""
|
"""Hook for performing custom form validation steps.
|
||||||
Hook for performing custom form validation steps.
|
|
||||||
|
|
||||||
If a form error is detected, add it to the form,
|
If a form error is detected, add it to the form,
|
||||||
with 'form.add_error()'
|
with 'form.add_error()'
|
||||||
|
|
||||||
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
|
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Do nothing by default
|
# Do nothing by default
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def renderJsonResponse(self, request, form=None, data=None, context=None):
|
def renderJsonResponse(self, request, form=None, data=None, context=None):
|
||||||
""" Render a JSON response based on specific class context.
|
"""Render a JSON response based on specific class context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: HTTP request object (e.g. GET / POST)
|
request: HTTP request object (e.g. GET / POST)
|
||||||
@ -318,8 +303,7 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
|
|
||||||
|
|
||||||
class AjaxView(AjaxMixin, View):
|
class AjaxView(AjaxMixin, View):
|
||||||
""" An 'AJAXified' View for displaying an object
|
"""An 'AJAXified' View for displaying an object."""
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
return self.renderJsonResponse(request)
|
return self.renderJsonResponse(request)
|
||||||
@ -330,7 +314,7 @@ class AjaxView(AjaxMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class QRCodeView(AjaxView):
|
class QRCodeView(AjaxView):
|
||||||
""" An 'AJAXified' view for displaying a QR code.
|
"""An 'AJAXified' view for displaying a QR code.
|
||||||
|
|
||||||
Subclasses should implement the get_qr_data(self) function.
|
Subclasses should implement the get_qr_data(self) function.
|
||||||
"""
|
"""
|
||||||
@ -343,17 +327,17 @@ class QRCodeView(AjaxView):
|
|||||||
return self.renderJsonResponse(request, None, context=self.get_context_data())
|
return self.renderJsonResponse(request, None, context=self.get_context_data())
|
||||||
|
|
||||||
def get_qr_data(self):
|
def get_qr_data(self):
|
||||||
""" Returns the text object to render to a QR code.
|
"""Returns the text object to render to a QR code.
|
||||||
The actual rendering will be handled by the template """
|
|
||||||
|
|
||||||
|
The actual rendering will be handled by the template
|
||||||
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
""" Get context data for passing to the rendering template.
|
"""Get context data for passing to the rendering template.
|
||||||
|
|
||||||
Explicity passes the parameter 'qr_data'
|
Explicity passes the parameter 'qr_data'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
qr = self.get_qr_data()
|
qr = self.get_qr_data()
|
||||||
@ -367,15 +351,14 @@ class QRCodeView(AjaxView):
|
|||||||
|
|
||||||
|
|
||||||
class AjaxCreateView(AjaxMixin, CreateView):
|
class AjaxCreateView(AjaxMixin, CreateView):
|
||||||
|
"""An 'AJAXified' CreateView for creating a new object in the db.
|
||||||
|
|
||||||
""" An 'AJAXified' CreateView for creating a new object in the db
|
|
||||||
- Returns a form in JSON format (for delivery to a modal window)
|
- Returns a form in JSON format (for delivery to a modal window)
|
||||||
- Handles form validation via AJAX POST requests
|
- Handles form validation via AJAX POST requests
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
""" Creates form with initial data, and renders JSON response """
|
"""Creates form with initial data, and renders JSON response."""
|
||||||
|
|
||||||
super(CreateView, self).get(request, *args, **kwargs)
|
super(CreateView, self).get(request, *args, **kwargs)
|
||||||
|
|
||||||
self.request = request
|
self.request = request
|
||||||
@ -383,18 +366,16 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
|||||||
return self.renderJsonResponse(request, form)
|
return self.renderJsonResponse(request, form)
|
||||||
|
|
||||||
def save(self, form):
|
def save(self, form):
|
||||||
"""
|
"""Method for actually saving the form to the database.
|
||||||
Method for actually saving the form to the database.
|
|
||||||
Default implementation is very simple,
|
|
||||||
but can be overridden if required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
Default implementation is very simple, but can be overridden if required.
|
||||||
|
"""
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
|
|
||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Responds to form POST. Validates POST data and returns status info.
|
"""Responds to form POST. Validates POST data and returns status info.
|
||||||
|
|
||||||
- Validate POST form data
|
- Validate POST form data
|
||||||
- If valid, save form
|
- If valid, save form
|
||||||
@ -441,45 +422,41 @@ class AjaxCreateView(AjaxMixin, CreateView):
|
|||||||
|
|
||||||
|
|
||||||
class AjaxUpdateView(AjaxMixin, UpdateView):
|
class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||||
""" An 'AJAXified' UpdateView for updating an object in the db
|
"""An 'AJAXified' UpdateView for updating an object in the db.
|
||||||
|
|
||||||
- Returns form in JSON format (for delivery to a modal window)
|
- Returns form in JSON format (for delivery to a modal window)
|
||||||
- Handles repeated form validation (via AJAX) until the form is valid
|
- Handles repeated form validation (via AJAX) until the form is valid
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
""" Respond to GET request.
|
"""Respond to GET request.
|
||||||
|
|
||||||
- Populates form with object data
|
- Populates form with object data
|
||||||
- Renders form to JSON and returns to client
|
- Renders form to JSON and returns to client
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super(UpdateView, self).get(request, *args, **kwargs)
|
super(UpdateView, self).get(request, *args, **kwargs)
|
||||||
|
|
||||||
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
|
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
|
||||||
|
|
||||||
def save(self, object, form, **kwargs):
|
def save(self, object, form, **kwargs):
|
||||||
"""
|
"""Method for updating the object in the database. Default implementation is very simple, but can be overridden if required.
|
||||||
Method for updating the object in the database.
|
|
||||||
Default implementation is very simple, but can be overridden if required.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
object - The current object, to be updated
|
object - The current object, to be updated
|
||||||
form - The validated form
|
form - The validated form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
|
|
||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Respond to POST request.
|
"""Respond to POST request.
|
||||||
|
|
||||||
- Updates model with POST field data
|
- Updates model with POST field data
|
||||||
- Performs form and object validation
|
- Performs form and object validation
|
||||||
- If errors exist, re-render the form
|
- If errors exist, re-render the form
|
||||||
- Otherwise, return sucess status
|
- Otherwise, return sucess status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
# Make sure we have an object to point to
|
# Make sure we have an object to point to
|
||||||
@ -524,8 +501,8 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class AjaxDeleteView(AjaxMixin, UpdateView):
|
class AjaxDeleteView(AjaxMixin, UpdateView):
|
||||||
|
"""An 'AJAXified DeleteView for removing an object from the DB.
|
||||||
|
|
||||||
""" An 'AJAXified DeleteView for removing an object from the DB
|
|
||||||
- Returns a HTML object (not a form!) in JSON format (for delivery to a modal window)
|
- Returns a HTML object (not a form!) in JSON format (for delivery to a modal window)
|
||||||
- Handles deletion
|
- Handles deletion
|
||||||
"""
|
"""
|
||||||
@ -546,12 +523,11 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
|
|||||||
return self.form_class(self.get_form_kwargs())
|
return self.form_class(self.get_form_kwargs())
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
""" Respond to GET request
|
"""Respond to GET request.
|
||||||
|
|
||||||
- Render a DELETE confirmation form to JSON
|
- Render a DELETE confirmation form to JSON
|
||||||
- Return rendered form to client
|
- Return rendered form to client
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super(UpdateView, self).get(request, *args, **kwargs)
|
super(UpdateView, self).get(request, *args, **kwargs)
|
||||||
|
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
@ -563,12 +539,11 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
|
|||||||
return self.renderJsonResponse(request, form, context=context)
|
return self.renderJsonResponse(request, form, context=context)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Respond to POST request
|
"""Respond to POST request.
|
||||||
|
|
||||||
- DELETE the object
|
- DELETE the object
|
||||||
- Render success message to JSON and return to client
|
- Render success message to JSON and return to client
|
||||||
"""
|
"""
|
||||||
|
|
||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
pk = obj.id
|
pk = obj.id
|
||||||
|
|
||||||
@ -592,7 +567,7 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class EditUserView(AjaxUpdateView):
|
class EditUserView(AjaxUpdateView):
|
||||||
""" View for editing user information """
|
"""View for editing user information."""
|
||||||
|
|
||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
ajax_form_title = _("Edit User Information")
|
ajax_form_title = _("Edit User Information")
|
||||||
@ -603,7 +578,7 @@ class EditUserView(AjaxUpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class SetPasswordView(AjaxUpdateView):
|
class SetPasswordView(AjaxUpdateView):
|
||||||
""" View for setting user password """
|
"""View for setting user password."""
|
||||||
|
|
||||||
ajax_template_name = "InvenTree/password.html"
|
ajax_template_name = "InvenTree/password.html"
|
||||||
ajax_form_title = _("Set Password")
|
ajax_form_title = _("Set Password")
|
||||||
@ -645,7 +620,7 @@ class SetPasswordView(AjaxUpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
""" View for InvenTree index page """
|
"""View for InvenTree index page."""
|
||||||
|
|
||||||
template_name = 'InvenTree/index.html'
|
template_name = 'InvenTree/index.html'
|
||||||
|
|
||||||
@ -657,7 +632,7 @@ class IndexView(TemplateView):
|
|||||||
|
|
||||||
|
|
||||||
class SearchView(TemplateView):
|
class SearchView(TemplateView):
|
||||||
""" View for InvenTree search page.
|
"""View for InvenTree search page.
|
||||||
|
|
||||||
Displays results of search query
|
Displays results of search query
|
||||||
"""
|
"""
|
||||||
@ -665,11 +640,10 @@ class SearchView(TemplateView):
|
|||||||
template_name = 'InvenTree/search.html'
|
template_name = 'InvenTree/search.html'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Handle POST request (which contains search query).
|
"""Handle POST request (which contains search query).
|
||||||
|
|
||||||
Pass the search query to the page template
|
Pass the search query to the page template
|
||||||
"""
|
"""
|
||||||
|
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
|
|
||||||
query = request.POST.get('search', '')
|
query = request.POST.get('search', '')
|
||||||
@ -680,19 +654,14 @@ class SearchView(TemplateView):
|
|||||||
|
|
||||||
|
|
||||||
class DynamicJsView(TemplateView):
|
class DynamicJsView(TemplateView):
|
||||||
"""
|
"""View for returning javacsript files, which instead of being served dynamically, are passed through the django translation engine!"""
|
||||||
View for returning javacsript files,
|
|
||||||
which instead of being served dynamically,
|
|
||||||
are passed through the django translation engine!
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = ""
|
template_name = ""
|
||||||
content_type = 'text/javascript'
|
content_type = 'text/javascript'
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(TemplateView):
|
class SettingsView(TemplateView):
|
||||||
""" View for configuring User settings
|
"""View for configuring User settings."""
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = "InvenTree/settings/settings.html"
|
template_name = "InvenTree/settings/settings.html"
|
||||||
|
|
||||||
@ -739,37 +708,29 @@ class SettingsView(TemplateView):
|
|||||||
|
|
||||||
|
|
||||||
class AllauthOverrides(LoginRequiredMixin):
|
class AllauthOverrides(LoginRequiredMixin):
|
||||||
"""
|
"""Override allauths views to always redirect to success_url."""
|
||||||
Override allauths views to always redirect to success_url
|
|
||||||
"""
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# always redirect to settings
|
# always redirect to settings
|
||||||
return HttpResponseRedirect(self.success_url)
|
return HttpResponseRedirect(self.success_url)
|
||||||
|
|
||||||
|
|
||||||
class CustomEmailView(AllauthOverrides, EmailView):
|
class CustomEmailView(AllauthOverrides, EmailView):
|
||||||
"""
|
"""Override of allauths EmailView to always show the settings but leave the functions allow."""
|
||||||
Override of allauths EmailView to always show the settings but leave the functions allow
|
|
||||||
"""
|
|
||||||
success_url = reverse_lazy("settings")
|
success_url = reverse_lazy("settings")
|
||||||
|
|
||||||
|
|
||||||
class CustomConnectionsView(AllauthOverrides, ConnectionsView):
|
class CustomConnectionsView(AllauthOverrides, ConnectionsView):
|
||||||
"""
|
"""Override of allauths ConnectionsView to always show the settings but leave the functions allow."""
|
||||||
Override of allauths ConnectionsView to always show the settings but leave the functions allow
|
|
||||||
"""
|
|
||||||
success_url = reverse_lazy("settings")
|
success_url = reverse_lazy("settings")
|
||||||
|
|
||||||
|
|
||||||
class CustomPasswordResetFromKeyView(PasswordResetFromKeyView):
|
class CustomPasswordResetFromKeyView(PasswordResetFromKeyView):
|
||||||
"""
|
"""Override of allauths PasswordResetFromKeyView to always show the settings but leave the functions allow."""
|
||||||
Override of allauths PasswordResetFromKeyView to always show the settings but leave the functions allow
|
|
||||||
"""
|
|
||||||
success_url = reverse_lazy("account_login")
|
success_url = reverse_lazy("account_login")
|
||||||
|
|
||||||
|
|
||||||
class UserSessionOverride():
|
class UserSessionOverride():
|
||||||
"""overrides sucessurl to lead to settings"""
|
"""overrides sucessurl to lead to settings."""
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return str(reverse_lazy('settings'))
|
return str(reverse_lazy('settings'))
|
||||||
|
|
||||||
@ -783,17 +744,12 @@ class CustomSessionDeleteOtherView(UserSessionOverride, SessionDeleteOtherView):
|
|||||||
|
|
||||||
|
|
||||||
class CurrencyRefreshView(RedirectView):
|
class CurrencyRefreshView(RedirectView):
|
||||||
"""
|
"""POST endpoint to refresh / update exchange rates."""
|
||||||
POST endpoint to refresh / update exchange rates
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse_lazy("settings-currencies")
|
url = reverse_lazy("settings-currencies")
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""
|
"""On a POST request we will attempt to refresh the exchange rates."""
|
||||||
On a POST request we will attempt to refresh the exchange rates
|
|
||||||
"""
|
|
||||||
|
|
||||||
from InvenTree.tasks import offload_task, update_exchange_rates
|
from InvenTree.tasks import offload_task, update_exchange_rates
|
||||||
|
|
||||||
offload_task(update_exchange_rates, force_sync=True)
|
offload_task(update_exchange_rates, force_sync=True)
|
||||||
@ -802,10 +758,10 @@ class CurrencyRefreshView(RedirectView):
|
|||||||
|
|
||||||
|
|
||||||
class AppearanceSelectView(RedirectView):
|
class AppearanceSelectView(RedirectView):
|
||||||
""" View for selecting a color theme """
|
"""View for selecting a color theme."""
|
||||||
|
|
||||||
def get_user_theme(self):
|
def get_user_theme(self):
|
||||||
""" Get current user color theme """
|
"""Get current user color theme."""
|
||||||
try:
|
try:
|
||||||
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
|
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
|
||||||
except ColorTheme.DoesNotExist:
|
except ColorTheme.DoesNotExist:
|
||||||
@ -814,8 +770,7 @@ class AppearanceSelectView(RedirectView):
|
|||||||
return user_theme
|
return user_theme
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Save user color theme selection """
|
"""Save user color theme selection."""
|
||||||
|
|
||||||
theme = request.POST.get('theme', None)
|
theme = request.POST.get('theme', None)
|
||||||
|
|
||||||
# Get current user theme
|
# Get current user theme
|
||||||
@ -833,15 +788,14 @@ class AppearanceSelectView(RedirectView):
|
|||||||
|
|
||||||
|
|
||||||
class SettingCategorySelectView(FormView):
|
class SettingCategorySelectView(FormView):
|
||||||
""" View for selecting categories in settings """
|
"""View for selecting categories in settings."""
|
||||||
|
|
||||||
form_class = SettingCategorySelectForm
|
form_class = SettingCategorySelectForm
|
||||||
success_url = reverse_lazy('settings-category')
|
success_url = reverse_lazy('settings-category')
|
||||||
template_name = "InvenTree/settings/category.html"
|
template_name = "InvenTree/settings/category.html"
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
""" Set category selection """
|
"""Set category selection."""
|
||||||
|
|
||||||
initial = super().get_initial()
|
initial = super().get_initial()
|
||||||
|
|
||||||
category = self.request.GET.get('category', None)
|
category = self.request.GET.get('category', None)
|
||||||
@ -851,11 +805,10 @@ class SettingCategorySelectView(FormView):
|
|||||||
return initial
|
return initial
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Handle POST request (which contains category selection).
|
"""Handle POST request (which contains category selection).
|
||||||
|
|
||||||
Pass the selected category to the page template
|
Pass the selected category to the page template
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -869,14 +822,13 @@ class SettingCategorySelectView(FormView):
|
|||||||
|
|
||||||
|
|
||||||
class DatabaseStatsView(AjaxView):
|
class DatabaseStatsView(AjaxView):
|
||||||
""" View for displaying database statistics """
|
"""View for displaying database statistics."""
|
||||||
|
|
||||||
ajax_template_name = "stats.html"
|
ajax_template_name = "stats.html"
|
||||||
ajax_form_title = _("System Information")
|
ajax_form_title = _("System Information")
|
||||||
|
|
||||||
|
|
||||||
class NotificationsView(TemplateView):
|
class NotificationsView(TemplateView):
|
||||||
""" View for showing notifications
|
"""View for showing notifications."""
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = "InvenTree/notifications/notifications.html"
|
template_name = "InvenTree/notifications/notifications.html"
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""WSGI config for InvenTree project.
|
||||||
WSGI config for InvenTree project.
|
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""The Build module is responsible for managing "Build" transactions.
|
||||||
The Build module is responsible for managing "Build" transactions.
|
|
||||||
|
|
||||||
A Build consumes parts from stock to create new parts
|
A Build consumes parts from stock to create new parts
|
||||||
"""
|
"""
|
||||||
|
@ -11,7 +11,7 @@ import part.models
|
|||||||
|
|
||||||
|
|
||||||
class BuildResource(ModelResource):
|
class BuildResource(ModelResource):
|
||||||
"""Class for managing import/export of Build data"""
|
"""Class for managing import/export of Build data."""
|
||||||
# For some reason, we need to specify the fields individually for this ModelResource,
|
# For some reason, we need to specify the fields individually for this ModelResource,
|
||||||
# but we don't for other ones.
|
# but we don't for other ones.
|
||||||
# TODO: 2022-05-12 - Need to investigate why this is the case!
|
# TODO: 2022-05-12 - Need to investigate why this is the case!
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""JSON API for the Build app."""
|
||||||
JSON API for the Build app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
@ -22,9 +20,7 @@ from users.models import Owner
|
|||||||
|
|
||||||
|
|
||||||
class BuildFilter(rest_filters.FilterSet):
|
class BuildFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom filterset for BuildList API endpoint."""
|
||||||
Custom filterset for BuildList API endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
status = rest_filters.NumberFilter(label='Status')
|
status = rest_filters.NumberFilter(label='Status')
|
||||||
|
|
||||||
@ -53,10 +49,7 @@ class BuildFilter(rest_filters.FilterSet):
|
|||||||
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
||||||
|
|
||||||
def filter_assigned_to_me(self, queryset, name, value):
|
def filter_assigned_to_me(self, queryset, name, value):
|
||||||
"""
|
"""Filter by orders which are assigned to the current user."""
|
||||||
Filter by orders which are assigned to the current user
|
|
||||||
"""
|
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
# Work out who "me" is!
|
# Work out who "me" is!
|
||||||
@ -71,7 +64,7 @@ class BuildFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
|
class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of Build objects.
|
"""API endpoint for accessing a list of Build objects.
|
||||||
|
|
||||||
- GET: Return list of objects (with filters)
|
- GET: Return list of objects (with filters)
|
||||||
- POST: Create a new Build object
|
- POST: Create a new Build object
|
||||||
@ -111,11 +104,7 @@ class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""Override the queryset filtering, as some of the fields don't natively play nicely with DRF."""
|
||||||
Override the queryset filtering,
|
|
||||||
as some of the fields don't natively play nicely with DRF
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().get_queryset().select_related('part')
|
queryset = super().get_queryset().select_related('part')
|
||||||
|
|
||||||
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
|
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
|
||||||
@ -207,15 +196,14 @@ class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildDetail(generics.RetrieveUpdateAPIView):
|
class BuildDetail(generics.RetrieveUpdateAPIView):
|
||||||
""" API endpoint for detail view of a Build object """
|
"""API endpoint for detail view of a Build object."""
|
||||||
|
|
||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = build.serializers.BuildSerializer
|
serializer_class = build.serializers.BuildSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildUnallocate(generics.CreateAPIView):
|
class BuildUnallocate(generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for unallocating stock items from a build order.
|
||||||
API endpoint for unallocating stock items from a build order
|
|
||||||
|
|
||||||
- The BuildOrder object is specified by the URL
|
- The BuildOrder object is specified by the URL
|
||||||
- "output" (StockItem) can optionally be specified
|
- "output" (StockItem) can optionally be specified
|
||||||
@ -241,7 +229,7 @@ class BuildUnallocate(generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOrderContextMixin:
|
class BuildOrderContextMixin:
|
||||||
""" Mixin class which adds build order as serializer context variable """
|
"""Mixin class which adds build order as serializer context variable."""
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
@ -258,9 +246,7 @@ class BuildOrderContextMixin:
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for creating new build output(s)"""
|
||||||
API endpoint for creating new build output(s)
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
@ -268,9 +254,7 @@ class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for completing build outputs."""
|
||||||
API endpoint for completing build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
@ -278,9 +262,7 @@ class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for deleting multiple build outputs."""
|
||||||
API endpoint for deleting multiple build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
@ -295,9 +277,7 @@ class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for marking a build as finished (completed)"""
|
||||||
API endpoint for marking a build as finished (completed)
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
@ -305,8 +285,7 @@ class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for 'automatically' allocating stock against a build order.
|
||||||
API endpoint for 'automatically' allocating stock against a build order.
|
|
||||||
|
|
||||||
- Only looks at 'untracked' parts
|
- Only looks at 'untracked' parts
|
||||||
- If stock exists in a single location, easy!
|
- If stock exists in a single location, easy!
|
||||||
@ -320,8 +299,7 @@ class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to allocate stock items to a build order.
|
||||||
API endpoint to allocate stock items to a build order
|
|
||||||
|
|
||||||
- The BuildOrder object is specified by the URL
|
- The BuildOrder object is specified by the URL
|
||||||
- Items to allocate are specified as a list called "items" with the following options:
|
- Items to allocate are specified as a list called "items" with the following options:
|
||||||
@ -337,23 +315,21 @@ class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView):
|
class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView):
|
||||||
""" API endpoint for cancelling a BuildOrder """
|
"""API endpoint for cancelling a BuildOrder."""
|
||||||
|
|
||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = build.serializers.BuildCancelSerializer
|
serializer_class = build.serializers.BuildCancelSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for detail view of a BuildItem object."""
|
||||||
API endpoint for detail view of a BuildItem object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = BuildItem.objects.all()
|
queryset = BuildItem.objects.all()
|
||||||
serializer_class = build.serializers.BuildItemSerializer
|
serializer_class = build.serializers.BuildItemSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildItemList(generics.ListCreateAPIView):
|
class BuildItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of BuildItem objects
|
"""API endpoint for accessing a list of BuildItem objects.
|
||||||
|
|
||||||
- GET: Return list of objects
|
- GET: Return list of objects
|
||||||
- POST: Create a new BuildItem object
|
- POST: Create a new BuildItem object
|
||||||
@ -375,10 +351,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Override the queryset method,
|
"""Override the queryset method, to allow filtering by stock_item.part."""
|
||||||
to allow filtering by stock_item.part
|
|
||||||
"""
|
|
||||||
|
|
||||||
query = BuildItem.objects.all()
|
query = BuildItem.objects.all()
|
||||||
|
|
||||||
query = query.select_related('stock_item__location')
|
query = query.select_related('stock_item__location')
|
||||||
@ -436,9 +409,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||||
API endpoint for listing (and creating) BuildOrderAttachment objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||||
@ -453,9 +424,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
||||||
"""
|
"""Detail endpoint for a BuildOrderAttachment object."""
|
||||||
Detail endpoint for a BuildOrderAttachment object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Build database model definitions."""
|
||||||
Build database model definitions
|
|
||||||
"""
|
|
||||||
|
|
||||||
import decimal
|
import decimal
|
||||||
|
|
||||||
@ -42,10 +40,7 @@ from users import models as UserModels
|
|||||||
|
|
||||||
|
|
||||||
def get_next_build_number():
|
def get_next_build_number():
|
||||||
"""
|
"""Returns the next available BuildOrder reference number."""
|
||||||
Returns the next available BuildOrder reference number
|
|
||||||
"""
|
|
||||||
|
|
||||||
if Build.objects.count() == 0:
|
if Build.objects.count() == 0:
|
||||||
return '0001'
|
return '0001'
|
||||||
|
|
||||||
@ -71,7 +66,7 @@ def get_next_build_number():
|
|||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel, ReferenceIndexingMixin):
|
class Build(MPTTModel, ReferenceIndexingMixin):
|
||||||
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
part: The part to be built (from component BOM items)
|
part: The part to be built (from component BOM items)
|
||||||
@ -109,10 +104,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def api_defaults(cls, request):
|
def api_defaults(cls, request):
|
||||||
"""
|
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||||
Return default values for this model when issuing an API OPTIONS request
|
|
||||||
"""
|
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'reference': get_next_build_number(),
|
'reference': get_next_build_number(),
|
||||||
}
|
}
|
||||||
@ -138,10 +130,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
verbose_name_plural = _("Build Orders")
|
verbose_name_plural = _("Build Orders")
|
||||||
|
|
||||||
def format_barcode(self, **kwargs):
|
def format_barcode(self, **kwargs):
|
||||||
"""
|
"""Return a JSON string to represent this build as a barcode."""
|
||||||
Return a JSON string to represent this build as a barcode
|
|
||||||
"""
|
|
||||||
|
|
||||||
return MakeBarcode(
|
return MakeBarcode(
|
||||||
"buildorder",
|
"buildorder",
|
||||||
self.pk,
|
self.pk,
|
||||||
@ -153,13 +142,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""
|
"""Filter by 'minimum and maximum date range'.
|
||||||
Filter by 'minimum and maximum date range'
|
|
||||||
|
|
||||||
- Specified as min_date, max_date
|
- Specified as min_date, max_date
|
||||||
- Both must be specified for filter to be applied
|
- Both must be specified for filter to be applied
|
||||||
"""
|
"""
|
||||||
|
|
||||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||||
|
|
||||||
# Ensure that both dates are valid
|
# Ensure that both dates are valid
|
||||||
@ -336,10 +323,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def sub_builds(self, cascade=True):
|
def sub_builds(self, cascade=True):
|
||||||
"""
|
"""Return all Build Order objects under this one."""
|
||||||
Return all Build Order objects under this one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if cascade:
|
if cascade:
|
||||||
return Build.objects.filter(parent=self.pk)
|
return Build.objects.filter(parent=self.pk)
|
||||||
else:
|
else:
|
||||||
@ -347,23 +331,19 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
||||||
|
|
||||||
def sub_build_count(self, cascade=True):
|
def sub_build_count(self, cascade=True):
|
||||||
"""
|
"""Return the number of sub builds under this one.
|
||||||
Return the number of sub builds under this one.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cascade: If True (defualt), include cascading builds under sub builds
|
cascade: If True (defualt), include cascading builds under sub builds
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.sub_builds(cascade=cascade).count()
|
return self.sub_builds(cascade=cascade).count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_overdue(self):
|
def is_overdue(self):
|
||||||
"""
|
"""Returns true if this build is "overdue":
|
||||||
Returns true if this build is "overdue":
|
|
||||||
|
|
||||||
Makes use of the OVERDUE_FILTER to avoid code duplication
|
Makes use of the OVERDUE_FILTER to avoid code duplication
|
||||||
"""
|
"""
|
||||||
|
|
||||||
query = Build.objects.filter(pk=self.pk)
|
query = Build.objects.filter(pk=self.pk)
|
||||||
query = query.filter(Build.OVERDUE_FILTER)
|
query = query.filter(Build.OVERDUE_FILTER)
|
||||||
|
|
||||||
@ -371,62 +351,41 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self):
|
def active(self):
|
||||||
"""
|
"""Return True if this build is active."""
|
||||||
Return True if this build is active
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.status in BuildStatus.ACTIVE_CODES
|
return self.status in BuildStatus.ACTIVE_CODES
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bom_items(self):
|
def bom_items(self):
|
||||||
"""
|
"""Returns the BOM items for the part referenced by this BuildOrder."""
|
||||||
Returns the BOM items for the part referenced by this BuildOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.part.get_bom_items()
|
return self.part.get_bom_items()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tracked_bom_items(self):
|
def tracked_bom_items(self):
|
||||||
"""
|
"""Returns the "trackable" BOM items for this BuildOrder."""
|
||||||
Returns the "trackable" BOM items for this BuildOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = self.bom_items
|
items = self.bom_items
|
||||||
items = items.filter(sub_part__trackable=True)
|
items = items.filter(sub_part__trackable=True)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def has_tracked_bom_items(self):
|
def has_tracked_bom_items(self):
|
||||||
"""
|
"""Returns True if this BuildOrder has trackable BomItems."""
|
||||||
Returns True if this BuildOrder has trackable BomItems
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.tracked_bom_items.count() > 0
|
return self.tracked_bom_items.count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def untracked_bom_items(self):
|
def untracked_bom_items(self):
|
||||||
"""
|
"""Returns the "non trackable" BOM items for this BuildOrder."""
|
||||||
Returns the "non trackable" BOM items for this BuildOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = self.bom_items
|
items = self.bom_items
|
||||||
items = items.filter(sub_part__trackable=False)
|
items = items.filter(sub_part__trackable=False)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def has_untracked_bom_items(self):
|
def has_untracked_bom_items(self):
|
||||||
"""
|
"""Returns True if this BuildOrder has non trackable BomItems."""
|
||||||
Returns True if this BuildOrder has non trackable BomItems
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.untracked_bom_items.count() > 0
|
return self.untracked_bom_items.count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def remaining(self):
|
def remaining(self):
|
||||||
"""
|
"""Return the number of outputs remaining to be completed."""
|
||||||
Return the number of outputs remaining to be completed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return max(0, self.quantity - self.completed)
|
return max(0, self.quantity - self.completed)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -437,14 +396,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return self.output_count > 0
|
return self.output_count > 0
|
||||||
|
|
||||||
def get_build_outputs(self, **kwargs):
|
def get_build_outputs(self, **kwargs):
|
||||||
"""
|
"""Return a list of build outputs.
|
||||||
Return a list of build outputs.
|
|
||||||
|
|
||||||
kwargs:
|
kwargs:
|
||||||
complete = (True / False) - If supplied, filter by completed status
|
complete = (True / False) - If supplied, filter by completed status
|
||||||
in_stock = (True / False) - If supplied, filter by 'in-stock' status
|
in_stock = (True / False) - If supplied, filter by 'in-stock' status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
outputs = self.build_outputs.all()
|
outputs = self.build_outputs.all()
|
||||||
|
|
||||||
# Filter by 'in stock' status
|
# Filter by 'in stock' status
|
||||||
@ -469,10 +426,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def complete_outputs(self):
|
def complete_outputs(self):
|
||||||
"""
|
"""Return all the "completed" build outputs."""
|
||||||
Return all the "completed" build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
outputs = self.get_build_outputs(complete=True)
|
outputs = self.get_build_outputs(complete=True)
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
@ -489,20 +443,14 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def incomplete_outputs(self):
|
def incomplete_outputs(self):
|
||||||
"""
|
"""Return all the "incomplete" build outputs."""
|
||||||
Return all the "incomplete" build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
outputs = self.get_build_outputs(complete=False)
|
outputs = self.get_build_outputs(complete=False)
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def incomplete_count(self):
|
def incomplete_count(self):
|
||||||
"""
|
"""Return the total number of "incomplete" outputs."""
|
||||||
Return the total number of "incomplete" outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
quantity = 0
|
quantity = 0
|
||||||
|
|
||||||
for output in self.incomplete_outputs:
|
for output in self.incomplete_outputs:
|
||||||
@ -512,10 +460,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getNextBuildNumber(cls):
|
def getNextBuildNumber(cls):
|
||||||
"""
|
"""Try to predict the next Build Order reference."""
|
||||||
Try to predict the next Build Order reference:
|
|
||||||
"""
|
|
||||||
|
|
||||||
if cls.objects.count() == 0:
|
if cls.objects.count() == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -552,13 +497,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def can_complete(self):
|
def can_complete(self):
|
||||||
"""
|
"""Returns True if this build can be "completed".
|
||||||
Returns True if this build can be "completed"
|
|
||||||
|
|
||||||
- Must not have any outstanding build outputs
|
- Must not have any outstanding build outputs
|
||||||
- 'completed' value must meet (or exceed) the 'quantity' value
|
- 'completed' value must meet (or exceed) the 'quantity' value
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.incomplete_count > 0:
|
if self.incomplete_count > 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -573,10 +516,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def complete_build(self, user):
|
def complete_build(self, user):
|
||||||
"""
|
"""Mark this build as complete."""
|
||||||
Mark this build as complete
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.incomplete_count > 0:
|
if self.incomplete_count > 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -597,13 +537,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def cancel_build(self, user, **kwargs):
|
def cancel_build(self, user, **kwargs):
|
||||||
""" Mark the Build as CANCELLED
|
"""Mark the Build as CANCELLED.
|
||||||
|
|
||||||
- Delete any pending BuildItem objects (but do not remove items from stock)
|
- Delete any pending BuildItem objects (but do not remove items from stock)
|
||||||
- Set build status to CANCELLED
|
- Set build status to CANCELLED
|
||||||
- Save the Build object
|
- Save the Build object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||||
|
|
||||||
@ -633,14 +572,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def unallocateStock(self, bom_item=None, output=None):
|
def unallocateStock(self, bom_item=None, output=None):
|
||||||
"""
|
"""Unallocate stock from this Build.
|
||||||
Unallocate stock from this Build
|
|
||||||
|
|
||||||
arguments:
|
Arguments:
|
||||||
- bom_item: Specify a particular BomItem to unallocate stock against
|
- bom_item: Specify a particular BomItem to unallocate stock against
|
||||||
- output: Specify a particular StockItem (output) to unallocate stock against
|
- output: Specify a particular StockItem (output) to unallocate stock against
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(
|
allocations = BuildItem.objects.filter(
|
||||||
build=self,
|
build=self,
|
||||||
install_into=output
|
install_into=output
|
||||||
@ -653,19 +590,17 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_build_output(self, quantity, **kwargs):
|
def create_build_output(self, quantity, **kwargs):
|
||||||
"""
|
"""Create a new build output against this BuildOrder.
|
||||||
Create a new build output against this BuildOrder.
|
|
||||||
|
|
||||||
args:
|
Args:
|
||||||
quantity: The quantity of the item to produce
|
quantity: The quantity of the item to produce
|
||||||
|
|
||||||
kwargs:
|
Kwargs:
|
||||||
batch: Override batch code
|
batch: Override batch code
|
||||||
serials: Serial numbers
|
serials: Serial numbers
|
||||||
location: Override location
|
location: Override location
|
||||||
auto_allocate: Automatically allocate stock with matching serial numbers
|
auto_allocate: Automatically allocate stock with matching serial numbers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
batch = kwargs.get('batch', self.batch)
|
batch = kwargs.get('batch', self.batch)
|
||||||
location = kwargs.get('location', self.destination)
|
location = kwargs.get('location', self.destination)
|
||||||
serials = kwargs.get('serials', None)
|
serials = kwargs.get('serials', None)
|
||||||
@ -687,9 +622,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
multiple = True
|
multiple = True
|
||||||
|
|
||||||
if multiple:
|
if multiple:
|
||||||
"""
|
"""Create multiple build outputs with a single quantity of 1."""
|
||||||
Create multiple build outputs with a single quantity of 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Quantity *must* be an integer at this point!
|
# Quantity *must* be an integer at this point!
|
||||||
quantity = int(quantity)
|
quantity = int(quantity)
|
||||||
@ -743,9 +676,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""
|
"""Create a single build output of the given quantity."""
|
||||||
Create a single build output of the given quantity
|
|
||||||
"""
|
|
||||||
|
|
||||||
StockModels.StockItem.objects.create(
|
StockModels.StockItem.objects.create(
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@ -762,13 +693,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete_output(self, output):
|
def delete_output(self, output):
|
||||||
"""
|
"""Remove a build output from the database:
|
||||||
Remove a build output from the database:
|
|
||||||
|
|
||||||
- Unallocate any build items against the output
|
- Unallocate any build items against the output
|
||||||
- Delete the output StockItem
|
- Delete the output StockItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not output:
|
if not output:
|
||||||
raise ValidationError(_("No build output specified"))
|
raise ValidationError(_("No build output specified"))
|
||||||
|
|
||||||
@ -786,11 +715,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def subtract_allocated_stock(self, user):
|
def subtract_allocated_stock(self, user):
|
||||||
"""
|
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
|
||||||
Called when the Build is marked as "complete",
|
|
||||||
this function removes the allocated untracked items from stock.
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = self.allocated_stock.filter(
|
items = self.allocated_stock.filter(
|
||||||
stock_item__part__trackable=False
|
stock_item__part__trackable=False
|
||||||
)
|
)
|
||||||
@ -804,13 +729,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def complete_build_output(self, output, user, **kwargs):
|
def complete_build_output(self, output, user, **kwargs):
|
||||||
"""
|
"""Complete a particular build output.
|
||||||
Complete a particular build output
|
|
||||||
|
|
||||||
- Remove allocated StockItems
|
- Remove allocated StockItems
|
||||||
- Mark the output as complete
|
- Mark the output as complete
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Select the location for the build output
|
# Select the location for the build output
|
||||||
location = kwargs.get('location', self.destination)
|
location = kwargs.get('location', self.destination)
|
||||||
status = kwargs.get('status', StockStatus.OK)
|
status = kwargs.get('status', StockStatus.OK)
|
||||||
@ -850,10 +773,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def auto_allocate_stock(self, **kwargs):
|
def auto_allocate_stock(self, **kwargs):
|
||||||
"""
|
"""Automatically allocate stock items against this build order.
|
||||||
Automatically allocate stock items against this build order,
|
|
||||||
following a number of 'guidelines':
|
|
||||||
|
|
||||||
|
Following a number of 'guidelines':
|
||||||
- Only "untracked" BOM items are considered (tracked BOM items must be manually allocated)
|
- Only "untracked" BOM items are considered (tracked BOM items must be manually allocated)
|
||||||
- If a particular BOM item is already fully allocated, it is skipped
|
- If a particular BOM item is already fully allocated, it is skipped
|
||||||
- Extract all available stock items for the BOM part
|
- Extract all available stock items for the BOM part
|
||||||
@ -863,7 +785,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
- If multiple stock items are found, we *may* be able to allocate:
|
- If multiple stock items are found, we *may* be able to allocate:
|
||||||
- If the calling function has specified that items are interchangeable
|
- If the calling function has specified that items are interchangeable
|
||||||
"""
|
"""
|
||||||
|
|
||||||
location = kwargs.get('location', None)
|
location = kwargs.get('location', None)
|
||||||
exclude_location = kwargs.get('exclude_location', None)
|
exclude_location = kwargs.get('exclude_location', None)
|
||||||
interchangeable = kwargs.get('interchangeable', False)
|
interchangeable = kwargs.get('interchangeable', False)
|
||||||
@ -958,14 +879,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def required_quantity(self, bom_item, output=None):
|
def required_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""Get the quantity of a part required to complete the particular build output.
|
||||||
Get the quantity of a part required to complete the particular build output.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
part: The Part object
|
part: The Part object
|
||||||
output - The particular build output (StockItem)
|
output - The particular build output (StockItem)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
quantity = bom_item.quantity
|
quantity = bom_item.quantity
|
||||||
|
|
||||||
if output:
|
if output:
|
||||||
@ -976,8 +895,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return quantity
|
return quantity
|
||||||
|
|
||||||
def allocated_bom_items(self, bom_item, output=None):
|
def allocated_bom_items(self, bom_item, output=None):
|
||||||
"""
|
"""Return all BuildItem objects which allocate stock of <bom_item> to <output>
|
||||||
Return all BuildItem objects which allocate stock of <bom_item> to <output>
|
|
||||||
|
|
||||||
Note that the bom_item may allow variants, or direct substitutes,
|
Note that the bom_item may allow variants, or direct substitutes,
|
||||||
making things difficult.
|
making things difficult.
|
||||||
@ -986,7 +904,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
bom_item - The BomItem object
|
bom_item - The BomItem object
|
||||||
output - Build output (StockItem).
|
output - Build output (StockItem).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
allocations = BuildItem.objects.filter(
|
allocations = BuildItem.objects.filter(
|
||||||
build=self,
|
build=self,
|
||||||
bom_item=bom_item,
|
bom_item=bom_item,
|
||||||
@ -996,10 +913,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return allocations
|
return allocations
|
||||||
|
|
||||||
def allocated_quantity(self, bom_item, output=None):
|
def allocated_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""Return the total quantity of given part allocated to a given build output."""
|
||||||
Return the total quantity of given part allocated to a given build output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = self.allocated_bom_items(bom_item, output)
|
allocations = self.allocated_bom_items(bom_item, output)
|
||||||
|
|
||||||
allocated = allocations.aggregate(
|
allocated = allocations.aggregate(
|
||||||
@ -1013,27 +927,18 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return allocated['q']
|
return allocated['q']
|
||||||
|
|
||||||
def unallocated_quantity(self, bom_item, output=None):
|
def unallocated_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""Return the total unallocated (remaining) quantity of a part against a particular output."""
|
||||||
Return the total unallocated (remaining) quantity of a part against a particular output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
required = self.required_quantity(bom_item, output)
|
required = self.required_quantity(bom_item, output)
|
||||||
allocated = self.allocated_quantity(bom_item, output)
|
allocated = self.allocated_quantity(bom_item, output)
|
||||||
|
|
||||||
return max(required - allocated, 0)
|
return max(required - allocated, 0)
|
||||||
|
|
||||||
def is_bom_item_allocated(self, bom_item, output=None):
|
def is_bom_item_allocated(self, bom_item, output=None):
|
||||||
"""
|
"""Test if the supplied BomItem has been fully allocated!"""
|
||||||
Test if the supplied BomItem has been fully allocated!
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.unallocated_quantity(bom_item, output) == 0
|
return self.unallocated_quantity(bom_item, output) == 0
|
||||||
|
|
||||||
def is_fully_allocated(self, output):
|
def is_fully_allocated(self, output):
|
||||||
"""
|
"""Returns True if the particular build output is fully allocated."""
|
||||||
Returns True if the particular build output is fully allocated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# If output is not specified, we are talking about "untracked" items
|
# If output is not specified, we are talking about "untracked" items
|
||||||
if output is None:
|
if output is None:
|
||||||
bom_items = self.untracked_bom_items
|
bom_items = self.untracked_bom_items
|
||||||
@ -1049,10 +954,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def is_partially_allocated(self, output):
|
def is_partially_allocated(self, output):
|
||||||
"""
|
"""Returns True if the particular build output is (at least) partially allocated."""
|
||||||
Returns True if the particular build output is (at least) partially allocated
|
|
||||||
"""
|
|
||||||
|
|
||||||
# If output is not specified, we are talking about "untracked" items
|
# If output is not specified, we are talking about "untracked" items
|
||||||
if output is None:
|
if output is None:
|
||||||
bom_items = self.untracked_bom_items
|
bom_items = self.untracked_bom_items
|
||||||
@ -1067,17 +969,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def are_untracked_parts_allocated(self):
|
def are_untracked_parts_allocated(self):
|
||||||
"""
|
"""Returns True if the un-tracked parts are fully allocated for this BuildOrder."""
|
||||||
Returns True if the un-tracked parts are fully allocated for this BuildOrder
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.is_fully_allocated(None)
|
return self.is_fully_allocated(None)
|
||||||
|
|
||||||
def unallocated_bom_items(self, output):
|
def unallocated_bom_items(self, output):
|
||||||
"""
|
"""Return a list of bom items which have *not* been fully allocated against a particular output."""
|
||||||
Return a list of bom items which have *not* been fully allocated against a particular output
|
|
||||||
"""
|
|
||||||
|
|
||||||
unallocated = []
|
unallocated = []
|
||||||
|
|
||||||
# If output is not specified, we are talking about "untracked" items
|
# If output is not specified, we are talking about "untracked" items
|
||||||
@ -1095,7 +991,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def required_parts(self):
|
def required_parts(self):
|
||||||
""" Returns a list of parts required to build this part (BOM) """
|
"""Returns a list of parts required to build this part (BOM)"""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
for item in self.bom_items:
|
for item in self.bom_items:
|
||||||
@ -1105,7 +1001,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def required_parts_to_complete_build(self):
|
def required_parts_to_complete_build(self):
|
||||||
""" Returns a list of parts required to complete the full build """
|
"""Returns a list of parts required to complete the full build."""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
for bom_item in self.bom_items:
|
for bom_item in self.bom_items:
|
||||||
@ -1119,26 +1015,23 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
""" Is this build active? An active build is either:
|
"""Is this build active?
|
||||||
|
|
||||||
|
An active build is either:
|
||||||
- PENDING
|
- PENDING
|
||||||
- HOLDING
|
- HOLDING
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.status in BuildStatus.ACTIVE_CODES
|
return self.status in BuildStatus.ACTIVE_CODES
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self):
|
def is_complete(self):
|
||||||
""" Returns True if the build status is COMPLETE """
|
"""Returns True if the build status is COMPLETE."""
|
||||||
|
|
||||||
return self.status == BuildStatus.COMPLETE
|
return self.status == BuildStatus.COMPLETE
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
|
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
|
||||||
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||||
"""
|
"""Callback function to be executed after a Build instance is saved."""
|
||||||
Callback function to be executed after a Build instance is saved
|
|
||||||
"""
|
|
||||||
from . import tasks as build_tasks
|
from . import tasks as build_tasks
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@ -1149,9 +1042,7 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOrderAttachment(InvenTreeAttachment):
|
class BuildOrderAttachment(InvenTreeAttachment):
|
||||||
"""
|
"""Model for storing file attachments against a BuildOrder object."""
|
||||||
Model for storing file attachments against a BuildOrder object
|
|
||||||
"""
|
|
||||||
|
|
||||||
def getSubdir(self):
|
def getSubdir(self):
|
||||||
return os.path.join('bo_files', str(self.build.id))
|
return os.path.join('bo_files', str(self.build.id))
|
||||||
@ -1160,10 +1051,9 @@ class BuildOrderAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
|
|
||||||
class BuildItem(models.Model):
|
class BuildItem(models.Model):
|
||||||
""" A BuildItem links multiple StockItem objects to a Build.
|
"""A BuildItem links multiple StockItem objects to a Build.
|
||||||
These are used to allocate part stock to a build.
|
|
||||||
Once the Build is completed, the parts are removed from stock and the
|
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.
|
||||||
BuildItemAllocation objects are removed.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
build: Link to a Build object
|
build: Link to a Build object
|
||||||
@ -1194,14 +1084,12 @@ class BuildItem(models.Model):
|
|||||||
super().save()
|
super().save()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""
|
"""Check validity of this BuildItem instance.
|
||||||
Check validity of this BuildItem instance.
|
|
||||||
The following checks are performed:
|
|
||||||
|
|
||||||
|
The following checks are performed:
|
||||||
- StockItem.part must be in the BOM of the Part object referenced by Build
|
- StockItem.part must be in the BOM of the Part object referenced by Build
|
||||||
- Allocation quantity cannot exceed available quantity
|
- Allocation quantity cannot exceed available quantity
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -1303,13 +1191,11 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def complete_allocation(self, user, notes=''):
|
def complete_allocation(self, user, notes=''):
|
||||||
"""
|
"""Complete the allocation of this BuildItem into the output stock item.
|
||||||
Complete the allocation of this BuildItem into the output stock item.
|
|
||||||
|
|
||||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||||
- If the referenced part is *not* trackable, the stock item will be removed from stock
|
- If the referenced part is *not* trackable, the stock item will be removed from stock
|
||||||
"""
|
"""
|
||||||
|
|
||||||
item = self.stock_item
|
item = self.stock_item
|
||||||
|
|
||||||
# For a trackable part, special consideration needed!
|
# For a trackable part, special consideration needed!
|
||||||
@ -1344,10 +1230,7 @@ class BuildItem(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def getStockItemThumbnail(self):
|
def getStockItemThumbnail(self):
|
||||||
"""
|
"""Return qualified URL for part thumbnail image."""
|
||||||
Return qualified URL for part thumbnail image
|
|
||||||
"""
|
|
||||||
|
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
|
|
||||||
if self.stock_item and self.stock_item.part:
|
if self.stock_item and self.stock_item.part:
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""JSON serializers for Build API."""
|
||||||
JSON serializers for Build API
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
||||||
@ -31,9 +29,7 @@ from .models import Build, BuildItem, BuildOrderAttachment
|
|||||||
|
|
||||||
|
|
||||||
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializes a Build object."""
|
||||||
Serializes a Build object
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
@ -50,16 +46,12 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
"""
|
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
|
||||||
Add custom annotations to the BuildSerializer queryset,
|
|
||||||
performing database queries as efficiently as possible.
|
|
||||||
|
|
||||||
The following annoted fields are added:
|
The following annoted fields are added:
|
||||||
|
|
||||||
- overdue: True if the build is outstanding *and* the completion date has past
|
- overdue: True if the build is outstanding *and* the completion date has past
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Annotate a boolean 'overdue' flag
|
# Annotate a boolean 'overdue' flag
|
||||||
|
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
@ -121,8 +113,7 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputSerializer(serializers.Serializer):
|
class BuildOutputSerializer(serializers.Serializer):
|
||||||
"""
|
"""Serializer for a "BuildOutput".
|
||||||
Serializer for a "BuildOutput"
|
|
||||||
|
|
||||||
Note that a "BuildOutput" is really just a StockItem which is "in production"!
|
Note that a "BuildOutput" is really just a StockItem which is "in production"!
|
||||||
"""
|
"""
|
||||||
@ -174,8 +165,7 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputCreateSerializer(serializers.Serializer):
|
class BuildOutputCreateSerializer(serializers.Serializer):
|
||||||
"""
|
"""Serializer for creating a new BuildOutput against a BuildOrder.
|
||||||
Serializer for creating a new BuildOutput against a BuildOrder.
|
|
||||||
|
|
||||||
URL pattern is "/api/build/<pk>/create-output/", where <pk> is the PK of a Build.
|
URL pattern is "/api/build/<pk>/create-output/", where <pk> is the PK of a Build.
|
||||||
|
|
||||||
@ -243,10 +233,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""Perform form validation."""
|
||||||
Perform form validation
|
|
||||||
"""
|
|
||||||
|
|
||||||
part = self.get_part()
|
part = self.get_part()
|
||||||
|
|
||||||
# Cache a list of serial numbers (to be used in the "save" method)
|
# Cache a list of serial numbers (to be used in the "save" method)
|
||||||
@ -284,10 +271,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""
|
"""Generate the new build output(s)"""
|
||||||
Generate the new build output(s)
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
quantity = data['quantity']
|
quantity = data['quantity']
|
||||||
@ -305,9 +289,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputDeleteSerializer(serializers.Serializer):
|
class BuildOutputDeleteSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for deleting (cancelling) one or more build outputs."""
|
||||||
DRF serializer for deleting (cancelling) one or more build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -331,10 +313,7 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""
|
"""'save' the serializer to delete the build outputs."""
|
||||||
'save' the serializer to delete the build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
outputs = data.get('outputs', [])
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
@ -347,9 +326,7 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for completing one or more build outputs."""
|
||||||
DRF serializer for completing one or more build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -404,10 +381,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""
|
"""Save the serializer to complete the build outputs."""
|
||||||
"save" the serializer to complete the build outputs
|
|
||||||
"""
|
|
||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
|
|
||||||
@ -481,9 +455,7 @@ class BuildCancelSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildCompleteSerializer(serializers.Serializer):
|
class BuildCompleteSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for marking a BuildOrder as complete."""
|
||||||
DRF serializer for marking a BuildOrder as complete
|
|
||||||
"""
|
|
||||||
|
|
||||||
accept_unallocated = serializers.BooleanField(
|
accept_unallocated = serializers.BooleanField(
|
||||||
label=_('Accept Unallocated'),
|
label=_('Accept Unallocated'),
|
||||||
@ -538,14 +510,12 @@ class BuildCompleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildUnallocationSerializer(serializers.Serializer):
|
class BuildUnallocationSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for unallocating stock from a BuildOrder.
|
||||||
DRF serializer for unallocating stock from a BuildOrder
|
|
||||||
|
|
||||||
Allocated stock can be unallocated with a number of filters:
|
Allocated stock can be unallocated with a number of filters:
|
||||||
|
|
||||||
- output: Filter against a particular build output (blank = untracked stock)
|
- output: Filter against a particular build output (blank = untracked stock)
|
||||||
- bom_item: Filter against a particular BOM line item
|
- bom_item: Filter against a particular BOM line item
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bom_item = serializers.PrimaryKeyRelatedField(
|
bom_item = serializers.PrimaryKeyRelatedField(
|
||||||
@ -577,11 +547,10 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
|||||||
return stock_item
|
return stock_item
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""
|
"""Save the serializer data.
|
||||||
'Save' the serializer data.
|
|
||||||
This performs the actual unallocation against the build order
|
This performs the actual unallocation against the build order
|
||||||
"""
|
"""
|
||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
@ -593,9 +562,7 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAllocationItemSerializer(serializers.Serializer):
|
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||||
"""
|
"""A serializer for allocating a single stock item against a build order."""
|
||||||
A serializer for allocating a single stock item against a build order
|
|
||||||
"""
|
|
||||||
|
|
||||||
bom_item = serializers.PrimaryKeyRelatedField(
|
bom_item = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=BomItem.objects.all(),
|
queryset=BomItem.objects.all(),
|
||||||
@ -606,10 +573,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_bom_item(self, bom_item):
|
def validate_bom_item(self, bom_item):
|
||||||
"""
|
"""Check if the parts match!"""
|
||||||
Check if the parts match!
|
|
||||||
"""
|
|
||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
# BomItem should point to the same 'part' as the parent build
|
# BomItem should point to the same 'part' as the parent build
|
||||||
@ -715,9 +679,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAllocationSerializer(serializers.Serializer):
|
class BuildAllocationSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for allocation stock items against a build order."""
|
||||||
DRF serializer for allocation stock items against a build order
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = BuildAllocationItemSerializer(many=True)
|
items = BuildAllocationItemSerializer(many=True)
|
||||||
|
|
||||||
@ -727,10 +689,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""Validation."""
|
||||||
Validation
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
|
|
||||||
items = data.get('items', [])
|
items = data.get('items', [])
|
||||||
@ -770,9 +729,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAutoAllocationSerializer(serializers.Serializer):
|
class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||||
"""
|
"""DRF serializer for auto allocating stock items against a build order."""
|
||||||
DRF serializer for auto allocating stock items against a build order
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -827,7 +784,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializes a BuildItem object """
|
"""Serializes a BuildItem object."""
|
||||||
|
|
||||||
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
||||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||||
@ -877,9 +834,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""Serializer for a BuildAttachment."""
|
||||||
Serializer for a BuildAttachment
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BuildOrderAttachment
|
model = BuildOrderAttachment
|
||||||
|
@ -18,11 +18,10 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
def check_build_stock(build: build.models.Build):
|
def check_build_stock(build: build.models.Build):
|
||||||
"""
|
"""Check the required stock for a newly created build order.
|
||||||
Check the required stock for a newly created build order,
|
|
||||||
and send an email out to any subscribed users if stock is low.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
Send an email out to any subscribed users if stock is low.
|
||||||
|
"""
|
||||||
# Do not notify if we are importing data
|
# Do not notify if we are importing data
|
||||||
if isImportingData():
|
if isImportingData():
|
||||||
return
|
return
|
||||||
|
@ -13,8 +13,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
|||||||
|
|
||||||
|
|
||||||
class TestBuildAPI(InvenTreeAPITestCase):
|
class TestBuildAPI(InvenTreeAPITestCase):
|
||||||
"""
|
"""Series of tests for the Build DRF API.
|
||||||
Series of tests for the Build DRF API
|
|
||||||
- Tests for Build API
|
- Tests for Build API
|
||||||
- Tests for BuildItem API
|
- Tests for BuildItem API
|
||||||
"""
|
"""
|
||||||
@ -33,10 +33,7 @@ class TestBuildAPI(InvenTreeAPITestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_get_build_list(self):
|
def test_get_build_list(self):
|
||||||
"""
|
"""Test that we can retrieve list of build objects."""
|
||||||
Test that we can retrieve list of build objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-build-list')
|
url = reverse('api-build-list')
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@ -65,7 +62,7 @@ class TestBuildAPI(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 0)
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
def test_get_build_item_list(self):
|
def test_get_build_item_list(self):
|
||||||
""" Test that we can retrieve list of BuildItem objects """
|
"""Test that we can retrieve list of BuildItem objects."""
|
||||||
url = reverse('api-build-item-list')
|
url = reverse('api-build-item-list')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
@ -77,9 +74,7 @@ class TestBuildAPI(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAPITest(InvenTreeAPITestCase):
|
class BuildAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""Series of tests for the Build DRF API."""
|
||||||
Series of tests for the Build DRF API
|
|
||||||
"""
|
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -102,9 +97,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class BuildTest(BuildAPITest):
|
class BuildTest(BuildAPITest):
|
||||||
"""
|
"""Unit testing for the build complete API endpoint."""
|
||||||
Unit testing for the build complete API endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
@ -115,10 +108,7 @@ class BuildTest(BuildAPITest):
|
|||||||
self.url = reverse('api-build-output-complete', kwargs={'pk': self.build.pk})
|
self.url = reverse('api-build-output-complete', kwargs={'pk': self.build.pk})
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
"""
|
"""Test with invalid data."""
|
||||||
Test with invalid data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Test with an invalid build ID
|
# Test with an invalid build ID
|
||||||
self.post(
|
self.post(
|
||||||
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
||||||
@ -199,10 +189,7 @@ class BuildTest(BuildAPITest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_complete(self):
|
def test_complete(self):
|
||||||
"""
|
"""Test build order completion."""
|
||||||
Test build order completion
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initially, build should not be able to be completed
|
# Initially, build should not be able to be completed
|
||||||
self.assertFalse(self.build.can_complete)
|
self.assertFalse(self.build.can_complete)
|
||||||
|
|
||||||
@ -270,8 +257,7 @@ class BuildTest(BuildAPITest):
|
|||||||
self.assertTrue(self.build.is_complete)
|
self.assertTrue(self.build.is_complete)
|
||||||
|
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
""" Test that we can cancel a BuildOrder via the API """
|
"""Test that we can cancel a BuildOrder via the API."""
|
||||||
|
|
||||||
bo = Build.objects.get(pk=1)
|
bo = Build.objects.get(pk=1)
|
||||||
|
|
||||||
url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
|
url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
|
||||||
@ -285,10 +271,7 @@ class BuildTest(BuildAPITest):
|
|||||||
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
||||||
|
|
||||||
def test_create_delete_output(self):
|
def test_create_delete_output(self):
|
||||||
"""
|
"""Test that we can create and delete build outputs via the API."""
|
||||||
Test that we can create and delete build outputs via the API
|
|
||||||
"""
|
|
||||||
|
|
||||||
bo = Build.objects.get(pk=1)
|
bo = Build.objects.get(pk=1)
|
||||||
|
|
||||||
n_outputs = bo.output_count
|
n_outputs = bo.output_count
|
||||||
@ -539,15 +522,13 @@ class BuildTest(BuildAPITest):
|
|||||||
|
|
||||||
|
|
||||||
class BuildAllocationTest(BuildAPITest):
|
class BuildAllocationTest(BuildAPITest):
|
||||||
"""
|
"""Unit tests for allocation of stock items against a build order.
|
||||||
Unit tests for allocation of stock items against a build order.
|
|
||||||
|
|
||||||
For this test, we will be using Build ID=1;
|
For this test, we will be using Build ID=1;
|
||||||
|
|
||||||
- This points to Part 100 (see fixture data in part.yaml)
|
- This points to Part 100 (see fixture data in part.yaml)
|
||||||
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
|
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
|
||||||
- There are no BomItem objects yet created for this build
|
- There are no BomItem objects yet created for this build
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -565,10 +546,7 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
self.n = BuildItem.objects.count()
|
self.n = BuildItem.objects.count()
|
||||||
|
|
||||||
def test_build_data(self):
|
def test_build_data(self):
|
||||||
"""
|
"""Check that our assumptions about the particular BuildOrder are correct."""
|
||||||
Check that our assumptions about the particular BuildOrder are correct
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.assertEqual(self.build.part.pk, 100)
|
self.assertEqual(self.build.part.pk, 100)
|
||||||
|
|
||||||
# There should be 4x BOM items we can use
|
# There should be 4x BOM items we can use
|
||||||
@ -578,26 +556,17 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||||
|
|
||||||
def test_get(self):
|
def test_get(self):
|
||||||
"""
|
"""A GET request to the endpoint should return an error."""
|
||||||
A GET request to the endpoint should return an error
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.get(self.url, expected_code=405)
|
self.get(self.url, expected_code=405)
|
||||||
|
|
||||||
def test_options(self):
|
def test_options(self):
|
||||||
"""
|
"""An OPTIONS request to the endpoint should return information about the endpoint."""
|
||||||
An OPTIONS request to the endpoint should return information about the endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = self.options(self.url, expected_code=200)
|
response = self.options(self.url, expected_code=200)
|
||||||
|
|
||||||
self.assertIn("API endpoint to allocate stock items to a build order", str(response.data))
|
self.assertIn("API endpoint to allocate stock items to a build order", str(response.data))
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
"""
|
"""Test without any POST data."""
|
||||||
Test without any POST data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initially test with an empty data set
|
# Initially test with an empty data set
|
||||||
data = self.post(self.url, {}, expected_code=400).data
|
data = self.post(self.url, {}, expected_code=400).data
|
||||||
|
|
||||||
@ -618,10 +587,7 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
self.assertEqual(self.n, BuildItem.objects.count())
|
self.assertEqual(self.n, BuildItem.objects.count())
|
||||||
|
|
||||||
def test_missing(self):
|
def test_missing(self):
|
||||||
"""
|
"""Test with missing data."""
|
||||||
Test with missing data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Missing quantity
|
# Missing quantity
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
@ -674,10 +640,7 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
self.assertEqual(self.n, BuildItem.objects.count())
|
self.assertEqual(self.n, BuildItem.objects.count())
|
||||||
|
|
||||||
def test_invalid_bom_item(self):
|
def test_invalid_bom_item(self):
|
||||||
"""
|
"""Test by passing an invalid BOM item."""
|
||||||
Test by passing an invalid BOM item
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -695,11 +658,10 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
self.assertIn('must point to the same part', str(data))
|
self.assertIn('must point to the same part', str(data))
|
||||||
|
|
||||||
def test_valid_data(self):
|
def test_valid_data(self):
|
||||||
"""
|
"""Test with valid data.
|
||||||
Test with valid data.
|
|
||||||
This should result in creation of a new BuildItem object
|
This should result in creation of a new BuildItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.post(
|
self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -725,17 +687,12 @@ class BuildAllocationTest(BuildAPITest):
|
|||||||
|
|
||||||
|
|
||||||
class BuildListTest(BuildAPITest):
|
class BuildListTest(BuildAPITest):
|
||||||
"""
|
"""Tests for the BuildOrder LIST API."""
|
||||||
Tests for the BuildOrder LIST API
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-build-list')
|
url = reverse('api-build-list')
|
||||||
|
|
||||||
def test_get_all_builds(self):
|
def test_get_all_builds(self):
|
||||||
"""
|
"""Retrieve *all* builds via the API."""
|
||||||
Retrieve *all* builds via the API
|
|
||||||
"""
|
|
||||||
|
|
||||||
builds = self.get(self.url)
|
builds = self.get(self.url)
|
||||||
|
|
||||||
self.assertEqual(len(builds.data), 5)
|
self.assertEqual(len(builds.data), 5)
|
||||||
@ -753,10 +710,7 @@ class BuildListTest(BuildAPITest):
|
|||||||
self.assertEqual(len(builds.data), 0)
|
self.assertEqual(len(builds.data), 0)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""
|
"""Create a new build, in the past."""
|
||||||
Create a new build, in the past
|
|
||||||
"""
|
|
||||||
|
|
||||||
in_the_past = datetime.now().date() - timedelta(days=50)
|
in_the_past = datetime.now().date() - timedelta(days=50)
|
||||||
|
|
||||||
part = Part.objects.get(pk=50)
|
part = Part.objects.get(pk=50)
|
||||||
@ -776,10 +730,7 @@ class BuildListTest(BuildAPITest):
|
|||||||
self.assertEqual(len(builds), 1)
|
self.assertEqual(len(builds), 1)
|
||||||
|
|
||||||
def test_sub_builds(self):
|
def test_sub_builds(self):
|
||||||
"""
|
"""Test the build / sub-build relationship."""
|
||||||
Test the build / sub-build relationship
|
|
||||||
"""
|
|
||||||
|
|
||||||
parent = Build.objects.get(pk=5)
|
parent = Build.objects.get(pk=5)
|
||||||
|
|
||||||
part = Part.objects.get(pk=50)
|
part = Part.objects.get(pk=50)
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -13,13 +11,10 @@ from stock.models import StockItem
|
|||||||
|
|
||||||
|
|
||||||
class BuildTestBase(TestCase):
|
class BuildTestBase(TestCase):
|
||||||
"""
|
"""Run some tests to ensure that the Build model is working properly."""
|
||||||
Run some tests to ensure that the Build model is working properly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""
|
"""Initialize data to use for these tests.
|
||||||
Initialize data to use for these tests.
|
|
||||||
|
|
||||||
The base Part 'assembly' has a BOM consisting of three parts:
|
The base Part 'assembly' has a BOM consisting of three parts:
|
||||||
|
|
||||||
@ -31,9 +26,7 @@ class BuildTestBase(TestCase):
|
|||||||
|
|
||||||
- 3 x output_1
|
- 3 x output_1
|
||||||
- 7 x output_2
|
- 7 x output_2
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Create a base "Part"
|
# Create a base "Part"
|
||||||
self.assembly = Part.objects.create(
|
self.assembly = Part.objects.create(
|
||||||
name="An assembled part",
|
name="An assembled part",
|
||||||
@ -122,10 +115,7 @@ class BuildTestBase(TestCase):
|
|||||||
class BuildTest(BuildTestBase):
|
class BuildTest(BuildTestBase):
|
||||||
|
|
||||||
def test_ref_int(self):
|
def test_ref_int(self):
|
||||||
"""
|
"""Test the "integer reference" field used for natural sorting."""
|
||||||
Test the "integer reference" field used for natural sorting
|
|
||||||
"""
|
|
||||||
|
|
||||||
for ii in range(10):
|
for ii in range(10):
|
||||||
build = Build(
|
build = Build(
|
||||||
reference=f"{ii}_abcde",
|
reference=f"{ii}_abcde",
|
||||||
@ -204,14 +194,12 @@ class BuildTest(BuildTestBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def allocate_stock(self, output, allocations):
|
def allocate_stock(self, output, allocations):
|
||||||
"""
|
"""Allocate stock to this build, against a particular output.
|
||||||
Allocate stock to this build, against a particular output
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
output - StockItem object (or None)
|
output - StockItem object (or None)
|
||||||
allocations - Map of {StockItem: quantity}
|
allocations - Map of {StockItem: quantity}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for item, quantity in allocations.items():
|
for item, quantity in allocations.items():
|
||||||
BuildItem.objects.create(
|
BuildItem.objects.create(
|
||||||
build=self.build,
|
build=self.build,
|
||||||
@ -221,10 +209,7 @@ class BuildTest(BuildTestBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_partial_allocation(self):
|
def test_partial_allocation(self):
|
||||||
"""
|
"""Test partial allocation of stock."""
|
||||||
Test partial allocation of stock
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Fully allocate tracked stock against build output 1
|
# Fully allocate tracked stock against build output 1
|
||||||
self.allocate_stock(
|
self.allocate_stock(
|
||||||
self.output_1,
|
self.output_1,
|
||||||
@ -296,10 +281,7 @@ class BuildTest(BuildTestBase):
|
|||||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||||
|
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
"""
|
"""Test cancellation of the build."""
|
||||||
Test cancellation of the build
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -311,10 +293,7 @@ class BuildTest(BuildTestBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_complete(self):
|
def test_complete(self):
|
||||||
"""
|
"""Test completion of a build output."""
|
||||||
Test completion of a build output
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.stock_1_1.quantity = 1000
|
self.stock_1_1.quantity = 1000
|
||||||
self.stock_1_1.save()
|
self.stock_1_1.save()
|
||||||
|
|
||||||
@ -387,9 +366,7 @@ class BuildTest(BuildTestBase):
|
|||||||
|
|
||||||
|
|
||||||
class AutoAllocationTests(BuildTestBase):
|
class AutoAllocationTests(BuildTestBase):
|
||||||
"""
|
"""Tests for auto allocating stock against a build order."""
|
||||||
Tests for auto allocating stock against a build order
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
@ -413,8 +390,7 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_auto_allocate(self):
|
def test_auto_allocate(self):
|
||||||
"""
|
"""Run the 'auto-allocate' function. What do we expect to happen?
|
||||||
Run the 'auto-allocate' function. What do we expect to happen?
|
|
||||||
|
|
||||||
There are two "untracked" parts:
|
There are two "untracked" parts:
|
||||||
- sub_part_1 (quantity 5 per BOM = 50 required total) / 103 in stock (2 items)
|
- sub_part_1 (quantity 5 per BOM = 50 required total) / 103 in stock (2 items)
|
||||||
@ -422,7 +398,6 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
|
|
||||||
A "fully auto" allocation should allocate *all* of these stock items to the build
|
A "fully auto" allocation should allocate *all* of these stock items to the build
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# No build item allocations have been made against the build
|
# No build item allocations have been made against the build
|
||||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||||
|
|
||||||
@ -476,10 +451,7 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2))
|
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||||
|
|
||||||
def test_fully_auto(self):
|
def test_fully_auto(self):
|
||||||
"""
|
"""We should be able to auto-allocate against a build in a single go."""
|
||||||
We should be able to auto-allocate against a build in a single go
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.build.auto_allocate_stock(
|
self.build.auto_allocate_stock(
|
||||||
interchangeable=True,
|
interchangeable=True,
|
||||||
substitutes=True
|
substitutes=True
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Tests for the build model database migrations."""
|
||||||
Tests for the build model database migrations
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
@ -8,18 +6,13 @@ from InvenTree import helpers
|
|||||||
|
|
||||||
|
|
||||||
class TestForwardMigrations(MigratorTestCase):
|
class TestForwardMigrations(MigratorTestCase):
|
||||||
"""
|
"""Test entire schema migration sequence for the build app."""
|
||||||
Test entire schema migration sequence for the build app
|
|
||||||
"""
|
|
||||||
|
|
||||||
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||||
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Create initial data!"""
|
||||||
Create initial data!
|
|
||||||
"""
|
|
||||||
|
|
||||||
Part = self.old_state.apps.get_model('part', 'part')
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
buildable_part = Part.objects.create(
|
buildable_part = Part.objects.create(
|
||||||
@ -63,18 +56,13 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestReferenceMigration(MigratorTestCase):
|
class TestReferenceMigration(MigratorTestCase):
|
||||||
"""
|
"""Test custom migration which adds 'reference' field to Build model."""
|
||||||
Test custom migration which adds 'reference' field to Build model
|
|
||||||
"""
|
|
||||||
|
|
||||||
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
migrate_from = ('build', helpers.getOldestMigrationFile('build'))
|
||||||
migrate_to = ('build', '0018_build_reference')
|
migrate_to = ('build', '0018_build_reference')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Create some builds."""
|
||||||
Create some builds
|
|
||||||
"""
|
|
||||||
|
|
||||||
Part = self.old_state.apps.get_model('part', 'part')
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
part = Part.objects.create(
|
part = Part.objects.create(
|
||||||
|
@ -48,10 +48,7 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
self.assertEqual(b2.status, BuildStatus.COMPLETE)
|
self.assertEqual(b2.status, BuildStatus.COMPLETE)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""
|
"""Test overdue status functionality."""
|
||||||
Test overdue status functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
|
|
||||||
build = Build.objects.get(pk=1)
|
build = Build.objects.get(pk=1)
|
||||||
@ -77,8 +74,7 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_cancel_build(self):
|
def test_cancel_build(self):
|
||||||
""" Test build cancellation function """
|
"""Test build cancellation function."""
|
||||||
|
|
||||||
build = Build.objects.get(id=1)
|
build = Build.objects.get(id=1)
|
||||||
|
|
||||||
self.assertEqual(build.status, BuildStatus.PENDING)
|
self.assertEqual(build.status, BuildStatus.PENDING)
|
||||||
@ -89,7 +85,7 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestBuildViews(InvenTreeTestCase):
|
class TestBuildViews(InvenTreeTestCase):
|
||||||
""" Tests for Build app views """
|
"""Tests for Build app views."""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -118,14 +114,12 @@ class TestBuildViews(InvenTreeTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_build_index(self):
|
def test_build_index(self):
|
||||||
""" test build index view """
|
"""Test build index view."""
|
||||||
|
|
||||||
response = self.client.get(reverse('build-index'))
|
response = self.client.get(reverse('build-index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_build_detail(self):
|
def test_build_detail(self):
|
||||||
""" Test the detail view for a Build object """
|
"""Test the detail view for a Build object."""
|
||||||
|
|
||||||
pk = 1
|
pk = 1
|
||||||
|
|
||||||
response = self.client.get(reverse('build-detail', args=(pk,)))
|
response = self.client.get(reverse('build-detail', args=(pk,)))
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""URL lookup for Build app."""
|
||||||
URL lookup for Build app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django views for interacting with Build objects."""
|
||||||
Django views for interacting with Build objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
||||||
@ -15,15 +13,13 @@ from plugin.views import InvenTreePluginViewMixin
|
|||||||
|
|
||||||
|
|
||||||
class BuildIndex(InvenTreeRoleMixin, ListView):
|
class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||||
"""
|
"""View for displaying list of Builds."""
|
||||||
View for displaying list of Builds
|
|
||||||
"""
|
|
||||||
model = Build
|
model = Build
|
||||||
template_name = 'build/index.html'
|
template_name = 'build/index.html'
|
||||||
context_object_name = 'builds'
|
context_object_name = 'builds'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Return all Build objects (order by date, newest first) """
|
"""Return all Build objects (order by date, newest first)"""
|
||||||
return Build.objects.order_by('status', '-completion_date')
|
return Build.objects.order_by('status', '-completion_date')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@ -41,9 +37,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""
|
"""Detail view of a single Build object."""
|
||||||
Detail view of a single Build object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
model = Build
|
||||||
template_name = 'build/detail.html'
|
template_name = 'build/detail.html'
|
||||||
@ -71,9 +65,7 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class BuildDelete(AjaxDeleteView):
|
class BuildDelete(AjaxDeleteView):
|
||||||
"""
|
"""View to delete a build."""
|
||||||
View to delete a build
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
model = Build
|
||||||
ajax_template_name = 'build/delete_build.html'
|
ajax_template_name = 'build/delete_build.html'
|
||||||
|
@ -10,10 +10,7 @@ class SettingsAdmin(ImportExportModelAdmin):
|
|||||||
list_display = ('key', 'value')
|
list_display = ('key', 'value')
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None): # pragma: no cover
|
def get_readonly_fields(self, request, obj=None): # pragma: no cover
|
||||||
"""
|
"""Prevent the 'key' field being edited once the setting is created."""
|
||||||
Prevent the 'key' field being edited once the setting is created
|
|
||||||
"""
|
|
||||||
|
|
||||||
if obj:
|
if obj:
|
||||||
return ['key']
|
return ['key']
|
||||||
else:
|
else:
|
||||||
@ -25,10 +22,7 @@ class UserSettingsAdmin(ImportExportModelAdmin):
|
|||||||
list_display = ('key', 'value', 'user', )
|
list_display = ('key', 'value', 'user', )
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None): # pragma: no cover
|
def get_readonly_fields(self, request, obj=None): # pragma: no cover
|
||||||
"""
|
"""Prevent the 'key' field being edited once the setting is created."""
|
||||||
Prevent the 'key' field being edited once the setting is created
|
|
||||||
"""
|
|
||||||
|
|
||||||
if obj:
|
if obj:
|
||||||
return ['key']
|
return ['key']
|
||||||
else:
|
else:
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Provides a JSON API for common components."""
|
||||||
Provides a JSON API for common components.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -24,9 +22,7 @@ from plugin.serializers import NotificationUserSettingSerializer
|
|||||||
|
|
||||||
|
|
||||||
class CsrfExemptMixin(object):
|
class CsrfExemptMixin(object):
|
||||||
"""
|
"""Exempts the view from CSRF requirements."""
|
||||||
Exempts the view from CSRF requirements.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
@method_decorator(csrf_exempt)
|
||||||
def dispatch(self, *args, **kwargs):
|
def dispatch(self, *args, **kwargs):
|
||||||
@ -34,9 +30,7 @@ class CsrfExemptMixin(object):
|
|||||||
|
|
||||||
|
|
||||||
class WebhookView(CsrfExemptMixin, APIView):
|
class WebhookView(CsrfExemptMixin, APIView):
|
||||||
"""
|
"""Endpoint for receiving webhooks."""
|
||||||
Endpoint for receiving webhooks.
|
|
||||||
"""
|
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
model_class = common.models.WebhookEndpoint
|
model_class = common.models.WebhookEndpoint
|
||||||
@ -120,24 +114,17 @@ class SettingsList(generics.ListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class GlobalSettingsList(SettingsList):
|
class GlobalSettingsList(SettingsList):
|
||||||
"""
|
"""API endpoint for accessing a list of global settings objects."""
|
||||||
API endpoint for accessing a list of global settings objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = common.models.InvenTreeSetting.objects.all()
|
queryset = common.models.InvenTreeSetting.objects.all()
|
||||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
class GlobalSettingsPermissions(permissions.BasePermission):
|
class GlobalSettingsPermissions(permissions.BasePermission):
|
||||||
"""
|
"""Special permission class to determine if the user is "staff"."""
|
||||||
Special permission class to determine if the user is "staff"
|
|
||||||
"""
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""
|
"""Check that the requesting user is 'admin'."""
|
||||||
Check that the requesting user is 'admin'
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@ -152,8 +139,7 @@ class GlobalSettingsPermissions(permissions.BasePermission):
|
|||||||
|
|
||||||
|
|
||||||
class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
|
class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
|
||||||
"""
|
"""Detail view for an individual "global setting" object.
|
||||||
Detail view for an individual "global setting" object.
|
|
||||||
|
|
||||||
- User must have 'staff' status to view / edit
|
- User must have 'staff' status to view / edit
|
||||||
"""
|
"""
|
||||||
@ -163,10 +149,7 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
"""
|
"""Attempt to find a global setting object with the provided key."""
|
||||||
Attempt to find a global setting object with the provided key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
key = self.kwargs['key']
|
key = self.kwargs['key']
|
||||||
|
|
||||||
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
|
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
|
||||||
@ -181,18 +164,13 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsList(SettingsList):
|
class UserSettingsList(SettingsList):
|
||||||
"""
|
"""API endpoint for accessing a list of user settings objects."""
|
||||||
API endpoint for accessing a list of user settings objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = common.models.InvenTreeUserSetting.objects.all()
|
queryset = common.models.InvenTreeUserSetting.objects.all()
|
||||||
serializer_class = common.serializers.UserSettingsSerializer
|
serializer_class = common.serializers.UserSettingsSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Only list settings which apply to the current user."""
|
||||||
Only list settings which apply to the current user
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
except AttributeError: # pragma: no cover
|
except AttributeError: # pragma: no cover
|
||||||
@ -206,9 +184,7 @@ class UserSettingsList(SettingsList):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsPermissions(permissions.BasePermission):
|
class UserSettingsPermissions(permissions.BasePermission):
|
||||||
"""
|
"""Special permission class to determine if the user can view / edit a particular setting."""
|
||||||
Special permission class to determine if the user can view / edit a particular setting
|
|
||||||
"""
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
|
|
||||||
@ -221,8 +197,7 @@ class UserSettingsPermissions(permissions.BasePermission):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsDetail(generics.RetrieveUpdateAPIView):
|
class UserSettingsDetail(generics.RetrieveUpdateAPIView):
|
||||||
"""
|
"""Detail view for an individual "user setting" object.
|
||||||
Detail view for an individual "user setting" object
|
|
||||||
|
|
||||||
- User can only view / edit settings their own settings objects
|
- User can only view / edit settings their own settings objects
|
||||||
"""
|
"""
|
||||||
@ -232,10 +207,7 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = common.serializers.UserSettingsSerializer
|
serializer_class = common.serializers.UserSettingsSerializer
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
"""
|
"""Attempt to find a user setting object with the provided key."""
|
||||||
Attempt to find a user setting object with the provided key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
key = self.kwargs['key']
|
key = self.kwargs['key']
|
||||||
|
|
||||||
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
|
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
|
||||||
@ -249,18 +221,13 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsList(SettingsList):
|
class NotificationUserSettingsList(SettingsList):
|
||||||
"""
|
"""API endpoint for accessing a list of notification user settings objects."""
|
||||||
API endpoint for accessing a list of notification user settings objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = NotificationUserSetting.objects.all()
|
queryset = NotificationUserSetting.objects.all()
|
||||||
serializer_class = NotificationUserSettingSerializer
|
serializer_class = NotificationUserSettingSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Only list settings which apply to the current user."""
|
||||||
Only list settings which apply to the current user
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@ -272,8 +239,7 @@ class NotificationUserSettingsList(SettingsList):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsDetail(generics.RetrieveUpdateAPIView):
|
class NotificationUserSettingsDetail(generics.RetrieveUpdateAPIView):
|
||||||
"""
|
"""Detail view for an individual "notification user setting" object.
|
||||||
Detail view for an individual "notification user setting" object
|
|
||||||
|
|
||||||
- User can only view / edit settings their own settings objects
|
- User can only view / edit settings their own settings objects
|
||||||
"""
|
"""
|
||||||
@ -313,10 +279,7 @@ class NotificationList(generics.ListAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Only list notifications which apply to the current user."""
|
||||||
Only list notifications which apply to the current user
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@ -328,8 +291,7 @@ class NotificationList(generics.ListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
|
class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""Detail view for an individual notification object.
|
||||||
Detail view for an individual notification object
|
|
||||||
|
|
||||||
- User can only view / delete their own notification objects
|
- User can only view / delete their own notification objects
|
||||||
"""
|
"""
|
||||||
@ -342,9 +304,7 @@ class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationReadEdit(generics.CreateAPIView):
|
class NotificationReadEdit(generics.CreateAPIView):
|
||||||
"""
|
"""General API endpoint to manipulate read state of a notification."""
|
||||||
general API endpoint to manipulate read state of a notification
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = common.models.NotificationMessage.objects.all()
|
queryset = common.models.NotificationMessage.objects.all()
|
||||||
serializer_class = common.serializers.NotificationReadSerializer
|
serializer_class = common.serializers.NotificationReadSerializer
|
||||||
@ -369,23 +329,17 @@ class NotificationReadEdit(generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationRead(NotificationReadEdit):
|
class NotificationRead(NotificationReadEdit):
|
||||||
"""
|
"""API endpoint to mark a notification as read."""
|
||||||
API endpoint to mark a notification as read.
|
|
||||||
"""
|
|
||||||
target = True
|
target = True
|
||||||
|
|
||||||
|
|
||||||
class NotificationUnread(NotificationReadEdit):
|
class NotificationUnread(NotificationReadEdit):
|
||||||
"""
|
"""API endpoint to mark a notification as unread."""
|
||||||
API endpoint to mark a notification as unread.
|
|
||||||
"""
|
|
||||||
target = False
|
target = False
|
||||||
|
|
||||||
|
|
||||||
class NotificationReadAll(generics.RetrieveAPIView):
|
class NotificationReadAll(generics.RetrieveAPIView):
|
||||||
"""
|
"""API endpoint to mark all notifications as read."""
|
||||||
API endpoint to mark all notifications as read.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = common.models.NotificationMessage.objects.all()
|
queryset = common.models.NotificationMessage.objects.all()
|
||||||
|
|
||||||
|
@ -15,10 +15,7 @@ class CommonConfig(AppConfig):
|
|||||||
self.clear_restart_flag()
|
self.clear_restart_flag()
|
||||||
|
|
||||||
def clear_restart_flag(self):
|
def clear_restart_flag(self):
|
||||||
"""
|
"""Clear the SERVER_RESTART_REQUIRED setting."""
|
||||||
Clear the SERVER_RESTART_REQUIRED setting
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Files management tools."""
|
||||||
Files management tools.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -12,7 +10,7 @@ from rapidfuzz import fuzz
|
|||||||
|
|
||||||
|
|
||||||
class FileManager:
|
class FileManager:
|
||||||
""" Class for managing an uploaded file """
|
"""Class for managing an uploaded file."""
|
||||||
|
|
||||||
name = ''
|
name = ''
|
||||||
|
|
||||||
@ -32,8 +30,7 @@ class FileManager:
|
|||||||
HEADERS = []
|
HEADERS = []
|
||||||
|
|
||||||
def __init__(self, file, name=None):
|
def __init__(self, file, name=None):
|
||||||
""" Initialize the FileManager class with a user-uploaded file object """
|
"""Initialize the FileManager class with a user-uploaded file object."""
|
||||||
|
|
||||||
# Set name
|
# Set name
|
||||||
if name:
|
if name:
|
||||||
self.name = name
|
self.name = name
|
||||||
@ -46,8 +43,7 @@ class FileManager:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, file):
|
def validate(cls, file):
|
||||||
""" Validate file extension and data """
|
"""Validate file extension and data."""
|
||||||
|
|
||||||
cleaned_data = None
|
cleaned_data = None
|
||||||
|
|
||||||
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
|
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
|
||||||
@ -79,21 +75,15 @@ class FileManager:
|
|||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
def process(self, file):
|
def process(self, file):
|
||||||
""" Process file """
|
"""Process file."""
|
||||||
|
|
||||||
self.data = self.__class__.validate(file)
|
self.data = self.__class__.validate(file)
|
||||||
|
|
||||||
def update_headers(self):
|
def update_headers(self):
|
||||||
""" Update headers """
|
"""Update headers."""
|
||||||
|
|
||||||
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
|
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""
|
"""Setup headers should be overriden in usage to set the Different Headers."""
|
||||||
Setup headers
|
|
||||||
should be overriden in usage to set the Different Headers
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -101,14 +91,12 @@ class FileManager:
|
|||||||
self.update_headers()
|
self.update_headers()
|
||||||
|
|
||||||
def guess_header(self, header, threshold=80):
|
def guess_header(self, header, threshold=80):
|
||||||
"""
|
"""Try to match a header (from the file) to a list of known headers.
|
||||||
Try to match a header (from the file) to a list of known headers
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
header - Header name to look for
|
header - Header name to look for
|
||||||
threshold - Match threshold for fuzzy search
|
threshold - Match threshold for fuzzy search
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Replace null values with empty string
|
# Replace null values with empty string
|
||||||
if header is None:
|
if header is None:
|
||||||
header = ''
|
header = ''
|
||||||
@ -143,7 +131,7 @@ class FileManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def columns(self):
|
def columns(self):
|
||||||
""" Return a list of headers for the thingy """
|
"""Return a list of headers for the thingy."""
|
||||||
headers = []
|
headers = []
|
||||||
|
|
||||||
for header in self.data.headers:
|
for header in self.data.headers:
|
||||||
@ -176,15 +164,14 @@ class FileManager:
|
|||||||
return len(self.data.headers)
|
return len(self.data.headers)
|
||||||
|
|
||||||
def row_count(self):
|
def row_count(self):
|
||||||
""" Return the number of rows in the file. """
|
"""Return the number of rows in the file."""
|
||||||
|
|
||||||
if self.data is None:
|
if self.data is None:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return len(self.data)
|
return len(self.data)
|
||||||
|
|
||||||
def rows(self):
|
def rows(self):
|
||||||
""" Return a list of all rows """
|
"""Return a list of all rows."""
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
for i in range(self.row_count()):
|
for i in range(self.row_count()):
|
||||||
@ -221,15 +208,14 @@ class FileManager:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
def get_row_data(self, index):
|
def get_row_data(self, index):
|
||||||
""" Retrieve row data at a particular index """
|
"""Retrieve row data at a particular index."""
|
||||||
if self.data is None or index >= len(self.data):
|
if self.data is None or index >= len(self.data):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self.data[index]
|
return self.data[index]
|
||||||
|
|
||||||
def get_row_dict(self, index):
|
def get_row_dict(self, index):
|
||||||
""" Retrieve a dict object representing the data row at a particular offset """
|
"""Retrieve a dict object representing the data row at a particular offset."""
|
||||||
|
|
||||||
if self.data is None or index >= len(self.data):
|
if self.data is None or index >= len(self.data):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django forms for interacting with common objects."""
|
||||||
Django forms for interacting with common objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -12,9 +10,7 @@ from .models import InvenTreeSetting
|
|||||||
|
|
||||||
|
|
||||||
class SettingEditForm(HelperForm):
|
class SettingEditForm(HelperForm):
|
||||||
"""
|
"""Form for creating / editing a settings object."""
|
||||||
Form for creating / editing a settings object
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InvenTreeSetting
|
model = InvenTreeSetting
|
||||||
@ -25,7 +21,7 @@ class SettingEditForm(HelperForm):
|
|||||||
|
|
||||||
|
|
||||||
class UploadFileForm(forms.Form):
|
class UploadFileForm(forms.Form):
|
||||||
""" Step 1 of FileManagementFormView """
|
"""Step 1 of FileManagementFormView."""
|
||||||
|
|
||||||
file = forms.FileField(
|
file = forms.FileField(
|
||||||
label=_('File'),
|
label=_('File'),
|
||||||
@ -33,8 +29,7 @@ class UploadFileForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" Update label and help_text """
|
"""Update label and help_text."""
|
||||||
|
|
||||||
# Get file name
|
# Get file name
|
||||||
name = None
|
name = None
|
||||||
if 'name' in kwargs:
|
if 'name' in kwargs:
|
||||||
@ -48,11 +43,10 @@ class UploadFileForm(forms.Form):
|
|||||||
self.fields['file'].help_text = _(f'Select {name} file to upload')
|
self.fields['file'].help_text = _(f'Select {name} file to upload')
|
||||||
|
|
||||||
def clean_file(self):
|
def clean_file(self):
|
||||||
"""
|
"""Run tabular file validation.
|
||||||
Run tabular file validation.
|
|
||||||
If anything is wrong with the file, it will raise ValidationError
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
If anything is wrong with the file, it will raise ValidationError
|
||||||
|
"""
|
||||||
file = self.cleaned_data['file']
|
file = self.cleaned_data['file']
|
||||||
|
|
||||||
# Validate file using FileManager class - will perform initial data validation
|
# Validate file using FileManager class - will perform initial data validation
|
||||||
@ -63,7 +57,7 @@ class UploadFileForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class MatchFieldForm(forms.Form):
|
class MatchFieldForm(forms.Form):
|
||||||
""" Step 2 of FileManagementFormView """
|
"""Step 2 of FileManagementFormView."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -96,7 +90,7 @@ class MatchFieldForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class MatchItemForm(forms.Form):
|
class MatchItemForm(forms.Form):
|
||||||
""" Step 3 of FileManagementFormView """
|
"""Step 3 of FileManagementFormView."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -194,6 +188,5 @@ class MatchItemForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_special_field(self, col_guess, row, file_manager):
|
def get_special_field(self, col_guess, row, file_manager):
|
||||||
""" Function to be overriden in inherited forms to add specific form settings """
|
"""Function to be overriden in inherited forms to add specific form settings."""
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""Common database model definitions.
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -55,10 +55,7 @@ class EmptyURLValidator(URLValidator):
|
|||||||
|
|
||||||
|
|
||||||
class BaseInvenTreeSetting(models.Model):
|
class BaseInvenTreeSetting(models.Model):
|
||||||
"""
|
"""An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values)."""
|
||||||
An base InvenTreeSetting object is a key:value pair used for storing
|
|
||||||
single values (e.g. one-off settings values).
|
|
||||||
"""
|
|
||||||
|
|
||||||
SETTINGS = {}
|
SETTINGS = {}
|
||||||
|
|
||||||
@ -66,10 +63,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""Enforce validation and clean before saving."""
|
||||||
Enforce validation and clean before saving
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.key = str(self.key).upper()
|
self.key = str(self.key).upper()
|
||||||
|
|
||||||
self.clean(**kwargs)
|
self.clean(**kwargs)
|
||||||
@ -79,14 +73,12 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def allValues(cls, user=None, exclude_hidden=False):
|
def allValues(cls, user=None, exclude_hidden=False):
|
||||||
"""
|
"""Return a dict of "all" defined global settings.
|
||||||
Return a dict of "all" defined global settings.
|
|
||||||
|
|
||||||
This performs a single database lookup,
|
This performs a single database lookup,
|
||||||
and then any settings which are not *in* the database
|
and then any settings which are not *in* the database
|
||||||
are assigned their default values
|
are assigned their default values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
results = cls.objects.all()
|
results = cls.objects.all()
|
||||||
|
|
||||||
# Optionally filter by user
|
# Optionally filter by user
|
||||||
@ -131,28 +123,23 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
def get_kwargs(self):
|
def get_kwargs(self):
|
||||||
"""
|
"""Construct kwargs for doing class-based settings lookup, depending on *which* class we are.
|
||||||
Construct kwargs for doing class-based settings lookup,
|
|
||||||
depending on *which* class we are.
|
|
||||||
|
|
||||||
This is necessary to abtract the settings object
|
This is necessary to abtract the settings object
|
||||||
from the implementing class (e.g plugins)
|
from the implementing class (e.g plugins)
|
||||||
|
|
||||||
Subclasses should override this function to ensure the kwargs are correctly set.
|
Subclasses should override this function to ensure the kwargs are correctly set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_definition(cls, key, **kwargs):
|
def get_setting_definition(cls, key, **kwargs):
|
||||||
"""
|
"""Return the 'definition' of a particular settings value, as a dict object.
|
||||||
Return the 'definition' of a particular settings value, as a dict object.
|
|
||||||
|
|
||||||
- The 'settings' dict can be passed as a kwarg
|
- The 'settings' dict can be passed as a kwarg
|
||||||
- If not passed, look for cls.SETTINGS
|
- If not passed, look for cls.SETTINGS
|
||||||
- Returns an empty dict if the key is not found
|
- Returns an empty dict if the key is not found
|
||||||
"""
|
"""
|
||||||
|
|
||||||
settings = kwargs.get('settings', cls.SETTINGS)
|
settings = kwargs.get('settings', cls.SETTINGS)
|
||||||
|
|
||||||
key = str(key).strip().upper()
|
key = str(key).strip().upper()
|
||||||
@ -164,69 +151,56 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_name(cls, key, **kwargs):
|
def get_setting_name(cls, key, **kwargs):
|
||||||
"""
|
"""Return the name of a particular setting.
|
||||||
Return the name of a particular setting.
|
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
If it does not exist, return an empty string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
return setting.get('name', '')
|
return setting.get('name', '')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_description(cls, key, **kwargs):
|
def get_setting_description(cls, key, **kwargs):
|
||||||
"""
|
"""Return the description for a particular setting.
|
||||||
Return the description for a particular setting.
|
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
If it does not exist, return an empty string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
return setting.get('description', '')
|
return setting.get('description', '')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_units(cls, key, **kwargs):
|
def get_setting_units(cls, key, **kwargs):
|
||||||
"""
|
"""Return the units for a particular setting.
|
||||||
Return the units for a particular setting.
|
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
If it does not exist, return an empty string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
return setting.get('units', '')
|
return setting.get('units', '')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_validator(cls, key, **kwargs):
|
def get_setting_validator(cls, key, **kwargs):
|
||||||
"""
|
"""Return the validator for a particular setting.
|
||||||
Return the validator for a particular setting.
|
|
||||||
|
|
||||||
If it does not exist, return None
|
If it does not exist, return None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
return setting.get('validator', None)
|
return setting.get('validator', None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_default(cls, key, **kwargs):
|
def get_setting_default(cls, key, **kwargs):
|
||||||
"""
|
"""Return the default value for a particular setting.
|
||||||
Return the default value for a particular setting.
|
|
||||||
|
|
||||||
If it does not exist, return an empty string
|
If it does not exist, return an empty string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
return setting.get('default', '')
|
return setting.get('default', '')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_choices(cls, key, **kwargs):
|
def get_setting_choices(cls, key, **kwargs):
|
||||||
"""
|
"""Return the validator choices available for a particular setting."""
|
||||||
Return the validator choices available for a particular setting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
choices = setting.get('choices', None)
|
choices = setting.get('choices', None)
|
||||||
@ -239,13 +213,11 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_object(cls, key, **kwargs):
|
def get_setting_object(cls, key, **kwargs):
|
||||||
"""
|
"""Return an InvenTreeSetting object matching the given key.
|
||||||
Return an InvenTreeSetting object matching the given key.
|
|
||||||
|
|
||||||
- Key is case-insensitive
|
- Key is case-insensitive
|
||||||
- Returns None if no match is made
|
- Returns None if no match is made
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key = str(key).strip().upper()
|
key = str(key).strip().upper()
|
||||||
|
|
||||||
settings = cls.objects.all()
|
settings = cls.objects.all()
|
||||||
@ -311,11 +283,10 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting(cls, key, backup_value=None, **kwargs):
|
def get_setting(cls, key, backup_value=None, **kwargs):
|
||||||
"""
|
"""Get the value of a particular setting.
|
||||||
Get the value of a particular setting.
|
|
||||||
If it does not exist, return the backup value (default = None)
|
If it does not exist, return the backup value (default = None)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If no backup value is specified, atttempt to retrieve a "default" value
|
# If no backup value is specified, atttempt to retrieve a "default" value
|
||||||
if backup_value is None:
|
if backup_value is None:
|
||||||
backup_value = cls.get_setting_default(key, **kwargs)
|
backup_value = cls.get_setting_default(key, **kwargs)
|
||||||
@ -343,9 +314,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_setting(cls, key, value, change_user, create=True, **kwargs):
|
def set_setting(cls, key, value, change_user, create=True, **kwargs):
|
||||||
"""
|
"""Set the value of a particular setting. If it does not exist, option to create it.
|
||||||
Set the value of a particular setting.
|
|
||||||
If it does not exist, option to create it.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: settings key
|
key: settings key
|
||||||
@ -353,7 +322,6 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
change_user: User object (must be staff member to update a core setting)
|
change_user: User object (must be staff member to update a core setting)
|
||||||
create: If True, create a new setting if the specified key does not exist.
|
create: If True, create a new setting if the specified key does not exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if change_user is not None and not change_user.is_staff:
|
if change_user is not None and not change_user.is_staff:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -412,11 +380,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return self.__class__.get_setting_units(self.key, **self.get_kwargs())
|
return self.__class__.get_setting_units(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
def clean(self, **kwargs):
|
def clean(self, **kwargs):
|
||||||
"""
|
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
|
||||||
If a validator (or multiple validators) are defined for a particular setting key,
|
|
||||||
run them against the 'value' field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Encode as native values
|
# Encode as native values
|
||||||
@ -437,10 +401,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
raise ValidationError(_("Chosen value is not a valid option"))
|
raise ValidationError(_("Chosen value is not a valid option"))
|
||||||
|
|
||||||
def run_validator(self, validator):
|
def run_validator(self, validator):
|
||||||
"""
|
"""Run a validator against the 'value' field for this InvenTreeSetting object."""
|
||||||
Run a validator against the 'value' field for this InvenTreeSetting object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if validator is None:
|
if validator is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -485,15 +446,11 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
validator(value)
|
validator(value)
|
||||||
|
|
||||||
def validate_unique(self, exclude=None, **kwargs):
|
def validate_unique(self, exclude=None, **kwargs):
|
||||||
"""
|
"""Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison.
|
||||||
Ensure that the key:value pair is unique.
|
|
||||||
In addition to the base validators, this ensures that the 'key'
|
|
||||||
is unique, using a case-insensitive comparison.
|
|
||||||
|
|
||||||
Note that sub-classes (UserSetting, PluginSetting) use other filters
|
Note that sub-classes (UserSetting, PluginSetting) use other filters
|
||||||
to determine if the setting is 'unique' or not
|
to determine if the setting is 'unique' or not
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().validate_unique(exclude)
|
super().validate_unique(exclude)
|
||||||
|
|
||||||
filters = {
|
filters = {
|
||||||
@ -520,17 +477,11 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def choices(self):
|
def choices(self):
|
||||||
"""
|
"""Return the available choices for this setting (or None if no choices are defined)"""
|
||||||
Return the available choices for this setting (or None if no choices are defined)
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.__class__.get_setting_choices(self.key, **self.get_kwargs())
|
return self.__class__.get_setting_choices(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
def valid_options(self):
|
def valid_options(self):
|
||||||
"""
|
"""Return a list of valid options for this setting."""
|
||||||
Return a list of valid options for this setting
|
|
||||||
"""
|
|
||||||
|
|
||||||
choices = self.choices()
|
choices = self.choices()
|
||||||
|
|
||||||
if not choices:
|
if not choices:
|
||||||
@ -539,21 +490,17 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return [opt[0] for opt in choices]
|
return [opt[0] for opt in choices]
|
||||||
|
|
||||||
def is_choice(self):
|
def is_choice(self):
|
||||||
"""
|
"""Check if this setting is a "choice" field."""
|
||||||
Check if this setting is a "choice" field
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None
|
return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None
|
||||||
|
|
||||||
def as_choice(self):
|
def as_choice(self):
|
||||||
"""
|
"""Render this setting as the "display" value of a choice field.
|
||||||
Render this setting as the "display" value of a choice field,
|
|
||||||
e.g. if the choices are:
|
E.g. if the choices are:
|
||||||
[('A4', 'A4 paper'), ('A3', 'A3 paper')],
|
[('A4', 'A4 paper'), ('A3', 'A3 paper')],
|
||||||
and the value is 'A4',
|
and the value is 'A4',
|
||||||
then display 'A4 paper'
|
then display 'A4 paper'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
choices = self.get_setting_choices(self.key, **self.get_kwargs())
|
choices = self.get_setting_choices(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
if not choices:
|
if not choices:
|
||||||
@ -566,30 +513,23 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
def is_model(self):
|
def is_model(self):
|
||||||
"""
|
"""Check if this setting references a model instance in the database."""
|
||||||
Check if this setting references a model instance in the database
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.model_name() is not None
|
return self.model_name() is not None
|
||||||
|
|
||||||
def model_name(self):
|
def model_name(self):
|
||||||
"""
|
"""Return the model name associated with this setting."""
|
||||||
Return the model name associated with this setting
|
|
||||||
"""
|
|
||||||
|
|
||||||
setting = self.get_setting_definition(self.key, **self.get_kwargs())
|
setting = self.get_setting_definition(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
return setting.get('model', None)
|
return setting.get('model', None)
|
||||||
|
|
||||||
def model_class(self):
|
def model_class(self):
|
||||||
"""
|
"""Return the model class associated with this setting.
|
||||||
Return the model class associated with this setting, if (and only if):
|
|
||||||
|
|
||||||
|
If (and only if):
|
||||||
- It has a defined 'model' parameter
|
- It has a defined 'model' parameter
|
||||||
- The 'model' parameter is of the form app.model
|
- The 'model' parameter is of the form app.model
|
||||||
- The 'model' parameter has matches a known app model
|
- The 'model' parameter has matches a known app model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_name = self.model_name()
|
model_name = self.model_name()
|
||||||
|
|
||||||
if not model_name:
|
if not model_name:
|
||||||
@ -617,11 +557,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
def api_url(self):
|
def api_url(self):
|
||||||
"""
|
"""Return the API url associated with the linked model, if provided, and valid!"""
|
||||||
Return the API url associated with the linked model,
|
|
||||||
if provided, and valid!
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_class = self.model_class()
|
model_class = self.model_class()
|
||||||
|
|
||||||
if model_class:
|
if model_class:
|
||||||
@ -634,28 +570,20 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def is_bool(self):
|
def is_bool(self):
|
||||||
"""
|
"""Check if this setting is required to be a boolean value."""
|
||||||
Check if this setting is required to be a boolean value
|
|
||||||
"""
|
|
||||||
|
|
||||||
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
|
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
return self.__class__.validator_is_bool(validator)
|
return self.__class__.validator_is_bool(validator)
|
||||||
|
|
||||||
def as_bool(self):
|
def as_bool(self):
|
||||||
"""
|
"""Return the value of this setting converted to a boolean value.
|
||||||
Return the value of this setting converted to a boolean value.
|
|
||||||
|
|
||||||
Warning: Only use on values where is_bool evaluates to true!
|
Warning: Only use on values where is_bool evaluates to true!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return InvenTree.helpers.str2bool(self.value)
|
return InvenTree.helpers.str2bool(self.value)
|
||||||
|
|
||||||
def setting_type(self):
|
def setting_type(self):
|
||||||
"""
|
"""Return the field type identifier for this setting object."""
|
||||||
Return the field type identifier for this setting object
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.is_bool():
|
if self.is_bool():
|
||||||
return 'boolean'
|
return 'boolean'
|
||||||
|
|
||||||
@ -682,10 +610,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_int(self,):
|
def is_int(self,):
|
||||||
"""
|
"""Check if the setting is required to be an integer value."""
|
||||||
Check if the setting is required to be an integer value:
|
|
||||||
"""
|
|
||||||
|
|
||||||
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
|
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
|
||||||
|
|
||||||
return self.__class__.validator_is_int(validator)
|
return self.__class__.validator_is_int(validator)
|
||||||
@ -704,12 +629,10 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def as_int(self):
|
def as_int(self):
|
||||||
"""
|
"""Return the value of this setting converted to a boolean value.
|
||||||
Return the value of this setting converted to a boolean value.
|
|
||||||
|
|
||||||
If an error occurs, return the default value
|
If an error occurs, return the default value
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = int(self.value)
|
value = int(self.value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@ -719,10 +642,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_protected(cls, key, **kwargs):
|
def is_protected(cls, key, **kwargs):
|
||||||
"""
|
"""Check if the setting value is protected."""
|
||||||
Check if the setting value is protected
|
|
||||||
"""
|
|
||||||
|
|
||||||
setting = cls.get_setting_definition(key, **kwargs)
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
return setting.get('protected', False)
|
return setting.get('protected', False)
|
||||||
@ -733,27 +653,22 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
def settings_group_options():
|
def settings_group_options():
|
||||||
"""
|
"""Build up group tuple for settings based on your choices."""
|
||||||
Build up group tuple for settings based on your choices
|
|
||||||
"""
|
|
||||||
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
|
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeSetting(BaseInvenTreeSetting):
|
class InvenTreeSetting(BaseInvenTreeSetting):
|
||||||
"""
|
"""An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
|
||||||
An InvenTreeSetting object is a key:value pair used for storing
|
|
||||||
single values (e.g. one-off settings values).
|
|
||||||
|
|
||||||
The class provides a way of retrieving the value for a particular key,
|
The class provides a way of retrieving the value for a particular key,
|
||||||
even if that key does not exist.
|
even if that key does not exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""When saving a global setting, check to see if it requires a server restart.
|
||||||
When saving a global setting, check to see if it requires a server restart.
|
|
||||||
If so, set the "SERVER_RESTART_REQUIRED" setting to True
|
If so, set the "SERVER_RESTART_REQUIRED" setting to True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().save()
|
super().save()
|
||||||
|
|
||||||
if self.requires_restart():
|
if self.requires_restart():
|
||||||
@ -1246,18 +1161,11 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def to_native_value(self):
|
def to_native_value(self):
|
||||||
"""
|
"""Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
|
||||||
Return the "pythonic" value,
|
|
||||||
e.g. convert "True" to True, and "1" to 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.__class__.get_setting(self.key)
|
return self.__class__.get_setting(self.key)
|
||||||
|
|
||||||
def requires_restart(self):
|
def requires_restart(self):
|
||||||
"""
|
"""Return True if this setting requires a server restart after changing."""
|
||||||
Return True if this setting requires a server restart after changing
|
|
||||||
"""
|
|
||||||
|
|
||||||
options = InvenTreeSetting.SETTINGS.get(self.key, None)
|
options = InvenTreeSetting.SETTINGS.get(self.key, None)
|
||||||
|
|
||||||
if options:
|
if options:
|
||||||
@ -1267,9 +1175,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeUserSetting(BaseInvenTreeSetting):
|
class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||||
"""
|
"""An InvenTreeSetting object with a usercontext."""
|
||||||
An InvenTreeSetting object with a usercontext
|
|
||||||
"""
|
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'HOMEPAGE_PART_STARRED': {
|
'HOMEPAGE_PART_STARRED': {
|
||||||
@ -1576,28 +1482,18 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
return super().validate_unique(exclude=exclude, user=self.user)
|
return super().validate_unique(exclude=exclude, user=self.user)
|
||||||
|
|
||||||
def to_native_value(self):
|
def to_native_value(self):
|
||||||
"""
|
"""Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
|
||||||
Return the "pythonic" value,
|
|
||||||
e.g. convert "True" to True, and "1" to 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.__class__.get_setting(self.key, user=self.user)
|
return self.__class__.get_setting(self.key, user=self.user)
|
||||||
|
|
||||||
def get_kwargs(self):
|
def get_kwargs(self):
|
||||||
"""
|
"""Explicit kwargs required to uniquely identify a particular setting object, in addition to the 'key' parameter."""
|
||||||
Explicit kwargs required to uniquely identify a particular setting object,
|
|
||||||
in addition to the 'key' parameter
|
|
||||||
"""
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'user': self.user,
|
'user': self.user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PriceBreak(models.Model):
|
class PriceBreak(models.Model):
|
||||||
"""
|
"""Represents a PriceBreak model."""
|
||||||
Represents a PriceBreak model
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@ -1620,13 +1516,11 @@ class PriceBreak(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def convert_to(self, currency_code):
|
def convert_to(self, currency_code):
|
||||||
"""
|
"""Convert the unit-price at this price break to the specified currency code.
|
||||||
Convert the unit-price at this price break to the specified currency code.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currency_code - The currency code to convert to (e.g "USD" or "AUD")
|
currency_code - The currency code to convert to (e.g "USD" or "AUD")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
converted = convert_money(self.price, currency_code)
|
converted = convert_money(self.price, currency_code)
|
||||||
except MissingRate:
|
except MissingRate:
|
||||||
@ -1637,7 +1531,7 @@ class PriceBreak(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
|
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
|
||||||
""" Calculate the price based on quantity price breaks.
|
"""Calculate the price based on quantity price breaks.
|
||||||
|
|
||||||
- Don't forget to add in flat-fee cost (base_cost field)
|
- Don't forget to add in flat-fee cost (base_cost field)
|
||||||
- If MOQ (minimum order quantity) is required, bump quantity
|
- If MOQ (minimum order quantity) is required, bump quantity
|
||||||
@ -1707,7 +1601,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
|
|||||||
|
|
||||||
|
|
||||||
class ColorTheme(models.Model):
|
class ColorTheme(models.Model):
|
||||||
""" Color Theme Setting """
|
"""Color Theme Setting."""
|
||||||
name = models.CharField(max_length=20,
|
name = models.CharField(max_length=20,
|
||||||
default='',
|
default='',
|
||||||
blank=True)
|
blank=True)
|
||||||
@ -1717,7 +1611,7 @@ class ColorTheme(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_color_themes_choices(cls):
|
def get_color_themes_choices(cls):
|
||||||
""" Get all color themes from static folder """
|
"""Get all color themes from static folder."""
|
||||||
if settings.TESTING and not os.path.exists(settings.STATIC_COLOR_THEMES_DIR):
|
if settings.TESTING and not os.path.exists(settings.STATIC_COLOR_THEMES_DIR):
|
||||||
logger.error('Theme directory does not exsist')
|
logger.error('Theme directory does not exsist')
|
||||||
return []
|
return []
|
||||||
@ -1736,7 +1630,7 @@ class ColorTheme(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_valid_choice(cls, user_color_theme):
|
def is_valid_choice(cls, user_color_theme):
|
||||||
""" Check if color theme is valid choice """
|
"""Check if color theme is valid choice."""
|
||||||
try:
|
try:
|
||||||
user_color_theme_name = user_color_theme.name
|
user_color_theme_name = user_color_theme.name
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@ -1756,7 +1650,7 @@ class VerificationMethod:
|
|||||||
|
|
||||||
|
|
||||||
class WebhookEndpoint(models.Model):
|
class WebhookEndpoint(models.Model):
|
||||||
""" Defines a Webhook entdpoint
|
"""Defines a Webhook entdpoint.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
endpoint_id: Path to the webhook,
|
endpoint_id: Path to the webhook,
|
||||||
@ -1868,7 +1762,7 @@ class WebhookEndpoint(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class WebhookMessage(models.Model):
|
class WebhookMessage(models.Model):
|
||||||
""" Defines a webhook message
|
"""Defines a webhook message.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
message_id: Unique identifier for this message,
|
message_id: Unique identifier for this message,
|
||||||
@ -1925,8 +1819,7 @@ class WebhookMessage(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationEntry(models.Model):
|
class NotificationEntry(models.Model):
|
||||||
"""
|
"""A NotificationEntry records the last time a particular notifaction was sent out.
|
||||||
A NotificationEntry records the last time a particular notifaction was sent out.
|
|
||||||
|
|
||||||
It is recorded to ensure that notifications are not sent out "too often" to users.
|
It is recorded to ensure that notifications are not sent out "too often" to users.
|
||||||
|
|
||||||
@ -1956,10 +1849,7 @@ class NotificationEntry(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_recent(cls, key: str, uid: int, delta: timedelta):
|
def check_recent(cls, key: str, uid: int, delta: timedelta):
|
||||||
"""
|
"""Test if a particular notification has been sent in the specified time period."""
|
||||||
Test if a particular notification has been sent in the specified time period
|
|
||||||
"""
|
|
||||||
|
|
||||||
since = datetime.now().date() - delta
|
since = datetime.now().date() - delta
|
||||||
|
|
||||||
entries = cls.objects.filter(
|
entries = cls.objects.filter(
|
||||||
@ -1972,10 +1862,7 @@ class NotificationEntry(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def notify(cls, key: str, uid: int):
|
def notify(cls, key: str, uid: int):
|
||||||
"""
|
"""Notify the database that a particular notification has been sent out."""
|
||||||
Notify the database that a particular notification has been sent out
|
|
||||||
"""
|
|
||||||
|
|
||||||
entry, created = cls.objects.get_or_create(
|
entry, created = cls.objects.get_or_create(
|
||||||
key=key,
|
key=key,
|
||||||
uid=uid
|
uid=uid
|
||||||
@ -1985,8 +1872,7 @@ class NotificationEntry(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationMessage(models.Model):
|
class NotificationMessage(models.Model):
|
||||||
"""
|
"""A NotificationEntry records the last time a particular notifaction was sent out.
|
||||||
A NotificationEntry records the last time a particular notifaction was sent out.
|
|
||||||
|
|
||||||
It is recorded to ensure that notifications are not sent out "too often" to users.
|
It is recorded to ensure that notifications are not sent out "too often" to users.
|
||||||
|
|
||||||
@ -2062,10 +1948,10 @@ class NotificationMessage(models.Model):
|
|||||||
return reverse('api-notifications-list')
|
return reverse('api-notifications-list')
|
||||||
|
|
||||||
def age(self):
|
def age(self):
|
||||||
"""age of the message in seconds"""
|
"""Age of the message in seconds."""
|
||||||
delta = now() - self.creation
|
delta = now() - self.creation
|
||||||
return delta.seconds
|
return delta.seconds
|
||||||
|
|
||||||
def age_human(self):
|
def age_human(self):
|
||||||
"""humanized age"""
|
"""Humanized age."""
|
||||||
return naturaltime(self.creation)
|
return naturaltime(self.creation)
|
||||||
|
@ -12,9 +12,7 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
# region methods
|
# region methods
|
||||||
class NotificationMethod:
|
class NotificationMethod:
|
||||||
"""
|
"""Base class for notification methods."""
|
||||||
Base class for notification methods
|
|
||||||
"""
|
|
||||||
|
|
||||||
METHOD_NAME = ''
|
METHOD_NAME = ''
|
||||||
METHOD_ICON = None
|
METHOD_ICON = None
|
||||||
@ -92,11 +90,11 @@ class NotificationMethod:
|
|||||||
|
|
||||||
# region plugins
|
# region plugins
|
||||||
def get_plugin(self):
|
def get_plugin(self):
|
||||||
"""Returns plugin class"""
|
"""Returns plugin class."""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def global_setting_disable(self):
|
def global_setting_disable(self):
|
||||||
"""Check if the method is defined in a plugin and has a global setting"""
|
"""Check if the method is defined in a plugin and has a global setting."""
|
||||||
# Check if plugin has a setting
|
# Check if plugin has a setting
|
||||||
if not self.GLOBAL_SETTING:
|
if not self.GLOBAL_SETTING:
|
||||||
return False
|
return False
|
||||||
@ -115,9 +113,7 @@ class NotificationMethod:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def usersetting(self, target):
|
def usersetting(self, target):
|
||||||
"""
|
"""Returns setting for this method for a given user."""
|
||||||
Returns setting for this method for a given user
|
|
||||||
"""
|
|
||||||
return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME)
|
return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME)
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
@ -204,10 +200,7 @@ class UIMessageNotification(SingleNotificationMethod):
|
|||||||
|
|
||||||
|
|
||||||
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
||||||
"""
|
"""Send out a notification."""
|
||||||
Send out a notification
|
|
||||||
"""
|
|
||||||
|
|
||||||
targets = kwargs.get('targets', None)
|
targets = kwargs.get('targets', None)
|
||||||
target_fnc = kwargs.get('target_fnc', None)
|
target_fnc = kwargs.get('target_fnc', None)
|
||||||
target_args = kwargs.get('target_args', [])
|
target_args = kwargs.get('target_args', [])
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""JSON serializers for common components."""
|
||||||
JSON serializers for common components
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
@ -11,9 +9,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
|
|||||||
|
|
||||||
|
|
||||||
class SettingsSerializer(InvenTreeModelSerializer):
|
class SettingsSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Base serializer for a settings object."""
|
||||||
Base serializer for a settings object
|
|
||||||
"""
|
|
||||||
|
|
||||||
key = serializers.CharField(read_only=True)
|
key = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
@ -30,10 +26,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
|||||||
api_url = serializers.CharField(read_only=True)
|
api_url = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
def get_choices(self, obj):
|
def get_choices(self, obj):
|
||||||
"""
|
"""Returns the choices available for a given item."""
|
||||||
Returns the choices available for a given item
|
|
||||||
"""
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
choices = obj.choices()
|
choices = obj.choices()
|
||||||
@ -48,10 +41,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
def get_value(self, obj):
|
def get_value(self, obj):
|
||||||
"""
|
"""Make sure protected values are not returned."""
|
||||||
Make sure protected values are not returned
|
|
||||||
"""
|
|
||||||
|
|
||||||
# never return protected values
|
# never return protected values
|
||||||
if obj.protected:
|
if obj.protected:
|
||||||
result = '***'
|
result = '***'
|
||||||
@ -62,9 +52,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class GlobalSettingsSerializer(SettingsSerializer):
|
class GlobalSettingsSerializer(SettingsSerializer):
|
||||||
"""
|
"""Serializer for the InvenTreeSetting model."""
|
||||||
Serializer for the InvenTreeSetting model
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InvenTreeSetting
|
model = InvenTreeSetting
|
||||||
@ -82,9 +70,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsSerializer(SettingsSerializer):
|
class UserSettingsSerializer(SettingsSerializer):
|
||||||
"""
|
"""Serializer for the InvenTreeUserSetting model."""
|
||||||
Serializer for the InvenTreeUserSetting model
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
|
|
||||||
@ -105,8 +91,7 @@ class UserSettingsSerializer(SettingsSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class GenericReferencedSettingSerializer(SettingsSerializer):
|
class GenericReferencedSettingSerializer(SettingsSerializer):
|
||||||
"""
|
"""Serializer for a GenericReferencedSetting model.
|
||||||
Serializer for a GenericReferencedSetting model
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
MODEL: model class for the serializer
|
MODEL: model class for the serializer
|
||||||
@ -118,9 +103,9 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
|||||||
EXTRA_FIELDS = None
|
EXTRA_FIELDS = None
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Init overrides the Meta class to make it dynamic"""
|
"""Init overrides the Meta class to make it dynamic."""
|
||||||
class CustomMeta:
|
class CustomMeta:
|
||||||
"""Scaffold for custom Meta class"""
|
"""Scaffold for custom Meta class."""
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'key',
|
'key',
|
||||||
@ -144,9 +129,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationMessageSerializer(InvenTreeModelSerializer):
|
class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializer for the InvenTreeUserSetting model."""
|
||||||
Serializer for the InvenTreeUserSetting model
|
|
||||||
"""
|
|
||||||
|
|
||||||
target = serializers.SerializerMethodField(read_only=True)
|
target = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""User-configurable settings for the common app."""
|
||||||
User-configurable settings for the common app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -8,9 +6,7 @@ from moneyed import CURRENCIES
|
|||||||
|
|
||||||
|
|
||||||
def currency_code_default():
|
def currency_code_default():
|
||||||
"""
|
"""Returns the default currency code (or USD if not specified)"""
|
||||||
Returns the default currency code (or USD if not specified)
|
|
||||||
"""
|
|
||||||
from django.db.utils import ProgrammingError
|
from django.db.utils import ProgrammingError
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
@ -28,23 +24,17 @@ def currency_code_default():
|
|||||||
|
|
||||||
|
|
||||||
def currency_code_mappings():
|
def currency_code_mappings():
|
||||||
"""
|
"""Returns the current currency choices."""
|
||||||
Returns the current currency choices
|
|
||||||
"""
|
|
||||||
return [(a, CURRENCIES[a].name) for a in settings.CURRENCIES]
|
return [(a, CURRENCIES[a].name) for a in settings.CURRENCIES]
|
||||||
|
|
||||||
|
|
||||||
def currency_codes():
|
def currency_codes():
|
||||||
"""
|
"""Returns the current currency codes."""
|
||||||
Returns the current currency codes
|
|
||||||
"""
|
|
||||||
return [a for a in settings.CURRENCIES]
|
return [a for a in settings.CURRENCIES]
|
||||||
|
|
||||||
|
|
||||||
def stock_expiry_enabled():
|
def stock_expiry_enabled():
|
||||||
"""
|
"""Returns True if the stock expiry feature is enabled."""
|
||||||
Returns True if the stock expiry feature is enabled
|
|
||||||
"""
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
|
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
|
||||||
|
@ -7,12 +7,10 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
def delete_old_notifications():
|
def delete_old_notifications():
|
||||||
"""
|
"""Remove old notifications from the database.
|
||||||
Remove old notifications from the database.
|
|
||||||
|
|
||||||
Anything older than ~3 months is removed
|
Anything older than ~3 months is removed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from common.models import NotificationEntry
|
from common.models import NotificationEntry
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
|
@ -8,7 +8,7 @@ from plugin.models import NotificationUserSetting
|
|||||||
class BaseNotificationTests(BaseNotificationIntegrationTest):
|
class BaseNotificationTests(BaseNotificationIntegrationTest):
|
||||||
|
|
||||||
def test_NotificationMethod(self):
|
def test_NotificationMethod(self):
|
||||||
"""ensure the implementation requirements are tested"""
|
"""Ensure the implementation requirements are tested."""
|
||||||
|
|
||||||
class FalseNotificationMethod(NotificationMethod):
|
class FalseNotificationMethod(NotificationMethod):
|
||||||
METHOD_NAME = 'FalseNotification'
|
METHOD_NAME = 'FalseNotification'
|
||||||
@ -17,12 +17,12 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
|||||||
METHOD_NAME = 'AnotherFalseNotification'
|
METHOD_NAME = 'AnotherFalseNotification'
|
||||||
|
|
||||||
def send(self):
|
def send(self):
|
||||||
"""a comment so we do not need a pass"""
|
"""A comment so we do not need a pass."""
|
||||||
|
|
||||||
class NoNameNotificationMethod(NotificationMethod):
|
class NoNameNotificationMethod(NotificationMethod):
|
||||||
|
|
||||||
def send(self):
|
def send(self):
|
||||||
"""a comment so we do not need a pass"""
|
"""A comment so we do not need a pass."""
|
||||||
|
|
||||||
class WrongContextNotificationMethod(NotificationMethod):
|
class WrongContextNotificationMethod(NotificationMethod):
|
||||||
METHOD_NAME = 'WrongContextNotification'
|
METHOD_NAME = 'WrongContextNotification'
|
||||||
@ -34,7 +34,7 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def send(self):
|
def send(self):
|
||||||
"""a comment so we do not need a pass"""
|
"""A comment so we do not need a pass."""
|
||||||
|
|
||||||
# no send / send bulk
|
# no send / send bulk
|
||||||
with self.assertRaises(NotImplementedError):
|
with self.assertRaises(NotImplementedError):
|
||||||
@ -57,7 +57,7 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
|||||||
self._notification_run()
|
self._notification_run()
|
||||||
|
|
||||||
def test_errors_passing(self):
|
def test_errors_passing(self):
|
||||||
"""ensure that errors do not kill the whole delivery"""
|
"""Ensure that errors do not kill the whole delivery."""
|
||||||
|
|
||||||
class ErrorImplementation(SingleNotificationMethod):
|
class ErrorImplementation(SingleNotificationMethod):
|
||||||
METHOD_NAME = 'ErrorImplementation'
|
METHOD_NAME = 'ErrorImplementation'
|
||||||
@ -74,8 +74,8 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
|||||||
class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||||
|
|
||||||
def test_BulkNotificationMethod(self):
|
def test_BulkNotificationMethod(self):
|
||||||
"""
|
"""Ensure the implementation requirements are tested.
|
||||||
Ensure the implementation requirements are tested.
|
|
||||||
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
|
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -92,8 +92,8 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
|||||||
class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||||
|
|
||||||
def test_SingleNotificationMethod(self):
|
def test_SingleNotificationMethod(self):
|
||||||
"""
|
"""Ensure the implementation requirements are tested.
|
||||||
Ensure the implementation requirements are tested.
|
|
||||||
MixinNotImplementedError needs to raise if the send() method is not set.
|
MixinNotImplementedError needs to raise if the send() method is not set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -110,14 +110,14 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingTests(BaseNotificationIntegrationTest):
|
class NotificationUserSettingTests(BaseNotificationIntegrationTest):
|
||||||
""" Tests for NotificationUserSetting """
|
"""Tests for NotificationUserSetting."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.client.login(username=self.user.username, password='password')
|
self.client.login(username=self.user.username, password='password')
|
||||||
|
|
||||||
def test_setting_attributes(self):
|
def test_setting_attributes(self):
|
||||||
"""check notification method plugin methods: usersettings and tags """
|
"""Check notification method plugin methods: usersettings and tags."""
|
||||||
|
|
||||||
class SampleImplementation(BulkNotificationMethod):
|
class SampleImplementation(BulkNotificationMethod):
|
||||||
METHOD_NAME = 'test'
|
METHOD_NAME = 'test'
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from common.models import NotificationEntry
|
from common.models import NotificationEntry
|
||||||
@ -8,9 +7,7 @@ from . import tasks as common_tasks
|
|||||||
|
|
||||||
|
|
||||||
class TaskTest(TestCase):
|
class TaskTest(TestCase):
|
||||||
"""
|
"""Tests for common tasks."""
|
||||||
Tests for common tasks
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
|
|
||||||
|
@ -1,3 +1 @@
|
|||||||
"""
|
"""Unit tests for the views associated with the 'common' app."""
|
||||||
Unit tests for the views associated with the 'common' app
|
|
||||||
"""
|
|
||||||
|
@ -19,9 +19,7 @@ CONTENT_TYPE_JSON = 'application/json'
|
|||||||
|
|
||||||
|
|
||||||
class SettingsTest(InvenTreeTestCase):
|
class SettingsTest(InvenTreeTestCase):
|
||||||
"""
|
"""Tests for the 'settings' model."""
|
||||||
Tests for the 'settings' model
|
|
||||||
"""
|
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'settings',
|
'settings',
|
||||||
@ -42,9 +40,7 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1)
|
self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1)
|
||||||
|
|
||||||
def test_settings_functions(self):
|
def test_settings_functions(self):
|
||||||
"""
|
"""Test settings functions and properties."""
|
||||||
Test settings functions and properties
|
|
||||||
"""
|
|
||||||
# define settings to check
|
# define settings to check
|
||||||
instance_ref = 'INVENTREE_INSTANCE'
|
instance_ref = 'INVENTREE_INSTANCE'
|
||||||
instance_obj = InvenTreeSetting.get_setting_object(instance_ref)
|
instance_obj = InvenTreeSetting.get_setting_object(instance_ref)
|
||||||
@ -90,9 +86,7 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
self.assertEqual(stale_days.to_native_value(), 0)
|
self.assertEqual(stale_days.to_native_value(), 0)
|
||||||
|
|
||||||
def test_allValues(self):
|
def test_allValues(self):
|
||||||
"""
|
"""Make sure that the allValues functions returns correctly."""
|
||||||
Make sure that the allValues functions returns correctly
|
|
||||||
"""
|
|
||||||
# define testing settings
|
# define testing settings
|
||||||
|
|
||||||
# check a few keys
|
# check a few keys
|
||||||
@ -147,11 +141,11 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
self.assertIn(default, [True, False])
|
self.assertIn(default, [True, False])
|
||||||
|
|
||||||
def test_setting_data(self):
|
def test_setting_data(self):
|
||||||
"""
|
"""Test for settings data.
|
||||||
|
|
||||||
- Ensure that every setting has a name, which is translated
|
- Ensure that every setting has a name, which is translated
|
||||||
- Ensure that every setting has a description, which is translated
|
- Ensure that every setting has a description, which is translated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for key, setting in InvenTreeSetting.SETTINGS.items():
|
for key, setting in InvenTreeSetting.SETTINGS.items():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -168,10 +162,7 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
"""
|
"""Populate the settings with default values."""
|
||||||
Populate the settings with default values
|
|
||||||
"""
|
|
||||||
|
|
||||||
for key in InvenTreeSetting.SETTINGS.keys():
|
for key in InvenTreeSetting.SETTINGS.keys():
|
||||||
|
|
||||||
value = InvenTreeSetting.get_setting_default(key)
|
value = InvenTreeSetting.get_setting_default(key)
|
||||||
@ -192,14 +183,10 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""Tests for the global settings API."""
|
||||||
Tests for the global settings API
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_global_settings_api_list(self):
|
def test_global_settings_api_list(self):
|
||||||
"""
|
"""Test list URL for global settings."""
|
||||||
Test list URL for global settings
|
|
||||||
"""
|
|
||||||
url = reverse('api-global-setting-list')
|
url = reverse('api-global-setting-list')
|
||||||
|
|
||||||
# Read out each of the global settings value, to ensure they are instantiated in the database
|
# Read out each of the global settings value, to ensure they are instantiated in the database
|
||||||
@ -246,7 +233,6 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_api_detail(self):
|
def test_api_detail(self):
|
||||||
"""Test that we can access the detail view for a setting based on the <key>"""
|
"""Test that we can access the detail view for a setting based on the <key>"""
|
||||||
|
|
||||||
# These keys are invalid, and should return 404
|
# These keys are invalid, and should return 404
|
||||||
for key in ["apple", "carrot", "dog"]:
|
for key in ["apple", "carrot", "dog"]:
|
||||||
response = self.get(
|
response = self.get(
|
||||||
@ -287,28 +273,22 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsApiTest(InvenTreeAPITestCase):
|
class UserSettingsApiTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""Tests for the user settings API."""
|
||||||
Tests for the user settings API
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_user_settings_api_list(self):
|
def test_user_settings_api_list(self):
|
||||||
"""
|
"""Test list URL for user settings."""
|
||||||
Test list URL for user settings
|
|
||||||
"""
|
|
||||||
url = reverse('api-user-setting-list')
|
url = reverse('api-user-setting-list')
|
||||||
|
|
||||||
self.get(url, expected_code=200)
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
def test_user_setting_invalid(self):
|
def test_user_setting_invalid(self):
|
||||||
"""Test a user setting with an invalid key"""
|
"""Test a user setting with an invalid key."""
|
||||||
|
|
||||||
url = reverse('api-user-setting-detail', kwargs={'key': 'DONKEY'})
|
url = reverse('api-user-setting-detail', kwargs={'key': 'DONKEY'})
|
||||||
|
|
||||||
self.get(url, expected_code=404)
|
self.get(url, expected_code=404)
|
||||||
|
|
||||||
def test_user_setting_init(self):
|
def test_user_setting_init(self):
|
||||||
"""Test we can retrieve a setting which has not yet been initialized"""
|
"""Test we can retrieve a setting which has not yet been initialized."""
|
||||||
|
|
||||||
key = 'HOMEPAGE_PART_LATEST'
|
key = 'HOMEPAGE_PART_LATEST'
|
||||||
|
|
||||||
# Ensure it does not actually exist in the database
|
# Ensure it does not actually exist in the database
|
||||||
@ -328,10 +308,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(setting.to_native_value(), False)
|
self.assertEqual(setting.to_native_value(), False)
|
||||||
|
|
||||||
def test_user_setting_boolean(self):
|
def test_user_setting_boolean(self):
|
||||||
"""
|
"""Test a boolean user setting value."""
|
||||||
Test a boolean user setting value
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Ensure we have a boolean setting available
|
# Ensure we have a boolean setting available
|
||||||
setting = InvenTreeUserSetting.get_setting_object(
|
setting = InvenTreeUserSetting.get_setting_object(
|
||||||
'SEARCH_PREVIEW_SHOW_PARTS',
|
'SEARCH_PREVIEW_SHOW_PARTS',
|
||||||
@ -480,25 +457,25 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
|
class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
|
||||||
"""Tests for the notification user settings API"""
|
"""Tests for the notification user settings API."""
|
||||||
|
|
||||||
def test_api_list(self):
|
def test_api_list(self):
|
||||||
"""Test list URL"""
|
"""Test list URL."""
|
||||||
url = reverse('api-notifcation-setting-list')
|
url = reverse('api-notifcation-setting-list')
|
||||||
|
|
||||||
self.get(url, expected_code=200)
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
def test_setting(self):
|
def test_setting(self):
|
||||||
"""Test the string name for NotificationUserSetting"""
|
"""Test the string name for NotificationUserSetting."""
|
||||||
test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user)
|
test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user)
|
||||||
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): ')
|
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): ')
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingsApiTest(InvenTreeAPITestCase):
|
class PluginSettingsApiTest(InvenTreeAPITestCase):
|
||||||
"""Tests for the plugin settings API"""
|
"""Tests for the plugin settings API."""
|
||||||
|
|
||||||
def test_plugin_list(self):
|
def test_plugin_list(self):
|
||||||
"""List installed plugins via API"""
|
"""List installed plugins via API."""
|
||||||
url = reverse('api-plugin-list')
|
url = reverse('api-plugin-list')
|
||||||
|
|
||||||
# Simple request
|
# Simple request
|
||||||
@ -508,13 +485,13 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
self.get(url, expected_code=200, data={'mixin': 'settings'})
|
self.get(url, expected_code=200, data={'mixin': 'settings'})
|
||||||
|
|
||||||
def test_api_list(self):
|
def test_api_list(self):
|
||||||
"""Test list URL"""
|
"""Test list URL."""
|
||||||
url = reverse('api-plugin-setting-list')
|
url = reverse('api-plugin-setting-list')
|
||||||
|
|
||||||
self.get(url, expected_code=200)
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
def test_valid_plugin_slug(self):
|
def test_valid_plugin_slug(self):
|
||||||
"""Test that an valid plugin slug runs through"""
|
"""Test that an valid plugin slug runs through."""
|
||||||
# load plugin configs
|
# load plugin configs
|
||||||
fixtures = PluginConfig.objects.all()
|
fixtures = PluginConfig.objects.all()
|
||||||
if not fixtures:
|
if not fixtures:
|
||||||
@ -544,11 +521,11 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data))
|
self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data))
|
||||||
|
|
||||||
def test_invalid_setting_key(self):
|
def test_invalid_setting_key(self):
|
||||||
"""Test that an invalid setting key returns a 404"""
|
"""Test that an invalid setting key returns a 404."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def test_uninitialized_setting(self):
|
def test_uninitialized_setting(self):
|
||||||
"""Test that requesting an uninitialized setting creates the setting"""
|
"""Test that requesting an uninitialized setting creates the setting."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@ -684,21 +661,16 @@ class NotificationTest(InvenTreeAPITestCase):
|
|||||||
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
|
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
|
||||||
|
|
||||||
def test_api_list(self):
|
def test_api_list(self):
|
||||||
"""Test list URL"""
|
"""Test list URL."""
|
||||||
url = reverse('api-notifications-list')
|
url = reverse('api-notifications-list')
|
||||||
self.get(url, expected_code=200)
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
|
||||||
class LoadingTest(TestCase):
|
class LoadingTest(TestCase):
|
||||||
"""
|
"""Tests for the common config."""
|
||||||
Tests for the common config
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_restart_flag(self):
|
def test_restart_flag(self):
|
||||||
"""
|
"""Test that the restart flag is reset on start."""
|
||||||
Test that the restart flag is reset on start
|
|
||||||
"""
|
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
@ -713,10 +685,10 @@ class LoadingTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ColorThemeTest(TestCase):
|
class ColorThemeTest(TestCase):
|
||||||
"""Tests for ColorTheme"""
|
"""Tests for ColorTheme."""
|
||||||
|
|
||||||
def test_choices(self):
|
def test_choices(self):
|
||||||
"""Test that default choices are returned"""
|
"""Test that default choices are returned."""
|
||||||
result = ColorTheme.get_color_themes_choices()
|
result = ColorTheme.get_color_themes_choices()
|
||||||
|
|
||||||
# skip
|
# skip
|
||||||
@ -725,7 +697,7 @@ class ColorThemeTest(TestCase):
|
|||||||
self.assertIn(('default', 'Default'), result)
|
self.assertIn(('default', 'Default'), result)
|
||||||
|
|
||||||
def test_valid_choice(self):
|
def test_valid_choice(self):
|
||||||
"""Check that is_valid_choice works correctly"""
|
"""Check that is_valid_choice works correctly."""
|
||||||
result = ColorTheme.get_color_themes_choices()
|
result = ColorTheme.get_color_themes_choices()
|
||||||
|
|
||||||
# skip
|
# skip
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""URL lookup for common views."""
|
||||||
URL lookup for common views
|
|
||||||
"""
|
|
||||||
|
|
||||||
common_urls = [
|
common_urls = [
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django views for interacting with common models."""
|
||||||
Django views for interacting with common models
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -18,10 +16,10 @@ from .files import FileManager
|
|||||||
|
|
||||||
|
|
||||||
class MultiStepFormView(SessionWizardView):
|
class MultiStepFormView(SessionWizardView):
|
||||||
""" Setup basic methods of multi-step form
|
"""Setup basic methods of multi-step form.
|
||||||
|
|
||||||
form_list: list of forms
|
form_list: list of forms
|
||||||
form_steps_description: description for each form
|
form_steps_description: description for each form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form_steps_template = []
|
form_steps_template = []
|
||||||
@ -31,14 +29,13 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
file_storage = FileSystemStorage(settings.MEDIA_ROOT)
|
file_storage = FileSystemStorage(settings.MEDIA_ROOT)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" Override init method to set media folder """
|
"""Override init method to set media folder."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.process_media_folder()
|
self.process_media_folder()
|
||||||
|
|
||||||
def process_media_folder(self):
|
def process_media_folder(self):
|
||||||
""" Process media folder """
|
"""Process media folder."""
|
||||||
|
|
||||||
if self.media_folder:
|
if self.media_folder:
|
||||||
media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder)
|
media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder)
|
||||||
if not os.path.exists(media_folder_abs):
|
if not os.path.exists(media_folder_abs):
|
||||||
@ -46,8 +43,7 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
self.file_storage = FileSystemStorage(location=media_folder_abs)
|
self.file_storage = FileSystemStorage(location=media_folder_abs)
|
||||||
|
|
||||||
def get_template_names(self):
|
def get_template_names(self):
|
||||||
""" Select template """
|
"""Select template."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get template
|
# Get template
|
||||||
template = self.form_steps_template[self.steps.index]
|
template = self.form_steps_template[self.steps.index]
|
||||||
@ -57,8 +53,7 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
""" Update context data """
|
"""Update context data."""
|
||||||
|
|
||||||
# Retrieve current context
|
# Retrieve current context
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
@ -74,7 +69,9 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
|
|
||||||
|
|
||||||
class FileManagementFormView(MultiStepFormView):
|
class FileManagementFormView(MultiStepFormView):
|
||||||
""" Setup form wizard to perform the following steps:
|
"""File management form wizard
|
||||||
|
|
||||||
|
Perform the following steps:
|
||||||
1. Upload tabular data file
|
1. Upload tabular data file
|
||||||
2. Match headers to InvenTree fields
|
2. Match headers to InvenTree fields
|
||||||
3. Edit row data and match InvenTree items
|
3. Edit row data and match InvenTree items
|
||||||
@ -95,8 +92,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
extra_context_data = {}
|
extra_context_data = {}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" Initialize the FormView """
|
"""Initialize the FormView."""
|
||||||
|
|
||||||
# Perform all checks and inits for MultiStepFormView
|
# Perform all checks and inits for MultiStepFormView
|
||||||
super().__init__(self, *args, **kwargs)
|
super().__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
@ -105,8 +101,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
raise NotImplementedError('A subclass of a file manager class needs to be set!')
|
raise NotImplementedError('A subclass of a file manager class needs to be set!')
|
||||||
|
|
||||||
def get_context_data(self, form=None, **kwargs):
|
def get_context_data(self, form=None, **kwargs):
|
||||||
""" Handle context data """
|
"""Handle context data."""
|
||||||
|
|
||||||
if form is None:
|
if form is None:
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
@ -136,8 +131,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_file_manager(self, step=None, form=None):
|
def get_file_manager(self, step=None, form=None):
|
||||||
""" Get FileManager instance from uploaded file """
|
"""Get FileManager instance from uploaded file."""
|
||||||
|
|
||||||
if self.file_manager:
|
if self.file_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -151,8 +145,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
self.file_manager = self.file_manager_class(file=file, name=self.name)
|
self.file_manager = self.file_manager_class(file=file, name=self.name)
|
||||||
|
|
||||||
def get_form_kwargs(self, step=None):
|
def get_form_kwargs(self, step=None):
|
||||||
""" Update kwargs to dynamically build forms """
|
"""Update kwargs to dynamically build forms."""
|
||||||
|
|
||||||
# Always retrieve FileManager instance from uploaded file
|
# Always retrieve FileManager instance from uploaded file
|
||||||
self.get_file_manager(step)
|
self.get_file_manager(step)
|
||||||
|
|
||||||
@ -191,7 +184,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return super().get_form_kwargs()
|
return super().get_form_kwargs()
|
||||||
|
|
||||||
def get_form(self, step=None, data=None, files=None):
|
def get_form(self, step=None, data=None, files=None):
|
||||||
""" add crispy-form helper to form """
|
"""Add crispy-form helper to form."""
|
||||||
form = super().get_form(step=step, data=data, files=files)
|
form = super().get_form(step=step, data=data, files=files)
|
||||||
|
|
||||||
form.helper = FormHelper()
|
form.helper = FormHelper()
|
||||||
@ -200,17 +193,14 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return form
|
return form
|
||||||
|
|
||||||
def get_form_table_data(self, form_data):
|
def get_form_table_data(self, form_data):
|
||||||
""" Extract table cell data from form data and fields.
|
"""Extract table cell data from form data and fields. These data are used to maintain state between sessions.
|
||||||
These data are used to maintain state between sessions.
|
|
||||||
|
|
||||||
Table data keys are as follows:
|
Table data keys are as follows:
|
||||||
|
|
||||||
col_name_<idx> - Column name at idx as provided in the uploaded file
|
col_name_<idx> - Column name at idx as provided in the uploaded file
|
||||||
col_guess_<idx> - Column guess at idx as selected
|
col_guess_<idx> - Column guess at idx as selected
|
||||||
row_<x>_col<y> - Cell data as provided in the uploaded file
|
row_<x>_col<y> - Cell data as provided in the uploaded file
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Map the columns
|
# Map the columns
|
||||||
self.column_names = {}
|
self.column_names = {}
|
||||||
self.column_selections = {}
|
self.column_selections = {}
|
||||||
@ -264,8 +254,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
self.row_data[row_id][col_id] = value
|
self.row_data[row_id][col_id] = value
|
||||||
|
|
||||||
def set_form_table_data(self, form=None):
|
def set_form_table_data(self, form=None):
|
||||||
""" Set the form table data """
|
"""Set the form table data."""
|
||||||
|
|
||||||
if self.column_names:
|
if self.column_names:
|
||||||
# Re-construct the column data
|
# Re-construct the column data
|
||||||
self.columns = []
|
self.columns = []
|
||||||
@ -324,10 +313,10 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
row[field_key] = field_key + '-' + str(row['index'])
|
row[field_key] = field_key + '-' + str(row['index'])
|
||||||
|
|
||||||
def get_column_index(self, name):
|
def get_column_index(self, name):
|
||||||
""" Return the index of the column with the given name.
|
"""Return the index of the column with the given name.
|
||||||
|
|
||||||
It named column is not found, return -1
|
It named column is not found, return -1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
idx = list(self.column_selections.values()).index(name)
|
idx = list(self.column_selections.values()).index(name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -336,9 +325,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return idx
|
return idx
|
||||||
|
|
||||||
def get_field_selection(self):
|
def get_field_selection(self):
|
||||||
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
|
"""Once data columns have been selected, attempt to pre-select the proper data from the database. This function is called once the field selection has been validated. The pre-fill data are then passed through to the part selection form.
|
||||||
This function is called once the field selection has been validated.
|
|
||||||
The pre-fill data are then passed through to the part selection form.
|
|
||||||
|
|
||||||
This method is very specific to the type of data found in the file,
|
This method is very specific to the type of data found in the file,
|
||||||
therefore overwrite it in the subclass.
|
therefore overwrite it in the subclass.
|
||||||
@ -346,7 +333,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def get_clean_items(self):
|
def get_clean_items(self):
|
||||||
""" returns dict with all cleaned values """
|
"""Returns dict with all cleaned values."""
|
||||||
items = {}
|
items = {}
|
||||||
|
|
||||||
for form_key, form_value in self.get_all_cleaned_data().items():
|
for form_key, form_value in self.get_all_cleaned_data().items():
|
||||||
@ -373,8 +360,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
def check_field_selection(self, form):
|
def check_field_selection(self, form):
|
||||||
""" Check field matching """
|
"""Check field matching."""
|
||||||
|
|
||||||
# Are there any missing columns?
|
# Are there any missing columns?
|
||||||
missing_columns = []
|
missing_columns = []
|
||||||
|
|
||||||
@ -422,8 +408,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return valid
|
return valid
|
||||||
|
|
||||||
def validate(self, step, form):
|
def validate(self, step, form):
|
||||||
""" Validate forms """
|
"""Validate forms."""
|
||||||
|
|
||||||
valid = True
|
valid = True
|
||||||
|
|
||||||
# Get form table data
|
# Get form table data
|
||||||
@ -442,8 +427,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return valid
|
return valid
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Perform validations before posting data """
|
"""Perform validations before posting data."""
|
||||||
|
|
||||||
wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
|
wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
|
||||||
|
|
||||||
form = self.get_form(data=self.request.POST, files=self.request.FILES)
|
form = self.get_form(data=self.request.POST, files=self.request.FILES)
|
||||||
@ -458,8 +442,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
|
|
||||||
|
|
||||||
class FileManagementAjaxView(AjaxView):
|
class FileManagementAjaxView(AjaxView):
|
||||||
""" Use a FileManagementFormView as base for a AjaxView
|
"""Use a FileManagementFormView as base for a AjaxView Inherit this class before inheriting the base FileManagementFormView.
|
||||||
Inherit this class before inheriting the base FileManagementFormView
|
|
||||||
|
|
||||||
ajax_form_steps_template: templates for rendering ajax
|
ajax_form_steps_template: templates for rendering ajax
|
||||||
validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView
|
validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView
|
||||||
@ -504,7 +487,7 @@ class FileManagementAjaxView(AjaxView):
|
|||||||
return self.renderJsonResponse(request)
|
return self.renderJsonResponse(request)
|
||||||
|
|
||||||
def renderJsonResponse(self, request, form=None, data={}, context=None):
|
def renderJsonResponse(self, request, form=None, data={}, context=None):
|
||||||
""" always set the right templates before rendering """
|
"""Always set the right templates before rendering."""
|
||||||
self.setTemplate()
|
self.setTemplate()
|
||||||
return super().renderJsonResponse(request, form=form, data=data, context=context)
|
return super().renderJsonResponse(request, form=form, data=data, context=context)
|
||||||
|
|
||||||
@ -516,7 +499,7 @@ class FileManagementAjaxView(AjaxView):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def setTemplate(self):
|
def setTemplate(self):
|
||||||
""" set template name and title """
|
"""Set template name and title."""
|
||||||
self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()]
|
self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()]
|
||||||
self.ajax_form_title = self.form_steps_description[self.get_step_index()]
|
self.ajax_form_title = self.form_steps_description[self.get_step_index()]
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
"""
|
"""The Company module is responsible for managing Company interactions.
|
||||||
The Company module is responsible for managing Company interactions.
|
|
||||||
|
|
||||||
A company can be either (or both):
|
A company can be either (or both):
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
|||||||
|
|
||||||
|
|
||||||
class CompanyResource(ModelResource):
|
class CompanyResource(ModelResource):
|
||||||
""" Class for managing Company data import/export """
|
"""Class for managing Company data import/export."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Company
|
model = Company
|
||||||
@ -35,9 +35,7 @@ class CompanyAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPartResource(ModelResource):
|
class SupplierPartResource(ModelResource):
|
||||||
"""
|
"""Class for managing SupplierPart data import/export."""
|
||||||
Class for managing SupplierPart data import/export
|
|
||||||
"""
|
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
|
|
||||||
@ -71,9 +69,7 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartResource(ModelResource):
|
class ManufacturerPartResource(ModelResource):
|
||||||
"""
|
"""Class for managing ManufacturerPart data import/export."""
|
||||||
Class for managing ManufacturerPart data import/export
|
|
||||||
"""
|
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
|
|
||||||
@ -91,9 +87,7 @@ class ManufacturerPartResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAdmin(ImportExportModelAdmin):
|
class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||||
"""
|
"""Admin class for ManufacturerPart model."""
|
||||||
Admin class for ManufacturerPart model
|
|
||||||
"""
|
|
||||||
|
|
||||||
resource_class = ManufacturerPartResource
|
resource_class = ManufacturerPartResource
|
||||||
|
|
||||||
@ -109,9 +103,7 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||||
"""
|
"""Admin class for ManufacturerPartAttachment model."""
|
||||||
Admin class for ManufacturerPartAttachment model
|
|
||||||
"""
|
|
||||||
|
|
||||||
list_display = ('manufacturer_part', 'attachment', 'comment')
|
list_display = ('manufacturer_part', 'attachment', 'comment')
|
||||||
|
|
||||||
@ -119,9 +111,7 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterResource(ModelResource):
|
class ManufacturerPartParameterResource(ModelResource):
|
||||||
"""
|
"""Class for managing ManufacturerPartParameter data import/export."""
|
||||||
Class for managing ManufacturerPartParameter data import/export
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ManufacturerPartParameter
|
model = ManufacturerPartParameter
|
||||||
@ -131,9 +121,7 @@ class ManufacturerPartParameterResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
||||||
"""
|
"""Admin class for ManufacturerPartParameter model."""
|
||||||
Admin class for ManufacturerPartParameter model
|
|
||||||
"""
|
|
||||||
|
|
||||||
resource_class = ManufacturerPartParameterResource
|
resource_class = ManufacturerPartParameterResource
|
||||||
|
|
||||||
@ -149,7 +137,7 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakResource(ModelResource):
|
class SupplierPriceBreakResource(ModelResource):
|
||||||
""" Class for managing SupplierPriceBreak data import/export """
|
"""Class for managing SupplierPriceBreak data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Provides a JSON API for the Company app."""
|
||||||
Provides a JSON API for the Company app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
@ -23,7 +21,7 @@ from .serializers import (CompanySerializer,
|
|||||||
|
|
||||||
|
|
||||||
class CompanyList(generics.ListCreateAPIView):
|
class CompanyList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of Company objects
|
"""API endpoint for accessing a list of Company objects.
|
||||||
|
|
||||||
Provides two methods:
|
Provides two methods:
|
||||||
|
|
||||||
@ -70,7 +68,7 @@ class CompanyList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
|
class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail of a single Company object """
|
"""API endpoint for detail of a single Company object."""
|
||||||
|
|
||||||
queryset = Company.objects.all()
|
queryset = Company.objects.all()
|
||||||
serializer_class = CompanySerializer
|
serializer_class = CompanySerializer
|
||||||
@ -84,9 +82,7 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartFilter(rest_filters.FilterSet):
|
class ManufacturerPartFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom API filters for the ManufacturerPart list endpoint."""
|
||||||
Custom API filters for the ManufacturerPart list endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ManufacturerPart
|
model = ManufacturerPart
|
||||||
@ -101,7 +97,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartList(generics.ListCreateAPIView):
|
class ManufacturerPartList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of ManufacturerPart object
|
"""API endpoint for list view of ManufacturerPart object.
|
||||||
|
|
||||||
- GET: Return list of ManufacturerPart objects
|
- GET: Return list of ManufacturerPart objects
|
||||||
- POST: Create a new ManufacturerPart object
|
- POST: Create a new ManufacturerPart object
|
||||||
@ -149,7 +145,7 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of ManufacturerPart object
|
"""API endpoint for detail view of ManufacturerPart object.
|
||||||
|
|
||||||
- GET: Retrieve detail view
|
- GET: Retrieve detail view
|
||||||
- PATCH: Update object
|
- PATCH: Update object
|
||||||
@ -161,9 +157,7 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
|
||||||
API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = ManufacturerPartAttachment.objects.all()
|
queryset = ManufacturerPartAttachment.objects.all()
|
||||||
serializer_class = ManufacturerPartAttachmentSerializer
|
serializer_class = ManufacturerPartAttachmentSerializer
|
||||||
@ -178,18 +172,14 @@ class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""Detail endpooint for ManufacturerPartAttachment model."""
|
||||||
Detail endpooint for ManufacturerPartAttachment model
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = ManufacturerPartAttachment.objects.all()
|
queryset = ManufacturerPartAttachment.objects.all()
|
||||||
serializer_class = ManufacturerPartAttachmentSerializer
|
serializer_class = ManufacturerPartAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for list view of ManufacturerPartParamater model."""
|
||||||
API endpoint for list view of ManufacturerPartParamater model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = ManufacturerPartParameter.objects.all()
|
queryset = ManufacturerPartParameter.objects.all()
|
||||||
serializer_class = ManufacturerPartParameterSerializer
|
serializer_class = ManufacturerPartParameterSerializer
|
||||||
@ -215,10 +205,7 @@ class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Custom filtering for the queryset."""
|
||||||
Custom filtering for the queryset
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -258,16 +245,14 @@ class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for detail view of ManufacturerPartParameter model."""
|
||||||
API endpoint for detail view of ManufacturerPartParameter model
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = ManufacturerPartParameter.objects.all()
|
queryset = ManufacturerPartParameter.objects.all()
|
||||||
serializer_class = ManufacturerPartParameterSerializer
|
serializer_class = ManufacturerPartParameterSerializer
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartList(generics.ListCreateAPIView):
|
class SupplierPartList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of SupplierPart object
|
"""API endpoint for list view of SupplierPart object.
|
||||||
|
|
||||||
- GET: Return list of SupplierPart objects
|
- GET: Return list of SupplierPart objects
|
||||||
- POST: Create a new SupplierPart object
|
- POST: Create a new SupplierPart object
|
||||||
@ -282,10 +267,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Custom filtering for the queryset."""
|
||||||
Custom filtering for the queryset.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -369,7 +351,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of SupplierPart object
|
"""API endpoint for detail view of SupplierPart object.
|
||||||
|
|
||||||
- GET: Retrieve detail view
|
- GET: Retrieve detail view
|
||||||
- PATCH: Update object
|
- PATCH: Update object
|
||||||
@ -384,7 +366,7 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakList(generics.ListCreateAPIView):
|
class SupplierPriceBreakList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of SupplierPriceBreak object
|
"""API endpoint for list view of SupplierPriceBreak object.
|
||||||
|
|
||||||
- GET: Retrieve list of SupplierPriceBreak objects
|
- GET: Retrieve list of SupplierPriceBreak objects
|
||||||
- POST: Create a new SupplierPriceBreak object
|
- POST: Create a new SupplierPriceBreak object
|
||||||
@ -403,9 +385,7 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""Detail endpoint for SupplierPriceBreak object."""
|
||||||
Detail endpoint for SupplierPriceBreak object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = SupplierPriceBreak.objects.all()
|
queryset = SupplierPriceBreak.objects.all()
|
||||||
serializer_class = SupplierPriceBreakSerializer
|
serializer_class = SupplierPriceBreakSerializer
|
||||||
|
@ -5,8 +5,5 @@ class CompanyConfig(AppConfig):
|
|||||||
name = 'company'
|
name = 'company'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""
|
"""This function is called whenever the Company app is loaded."""
|
||||||
This function is called whenever the Company app is loaded.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django Forms for interacting with Company app."""
|
||||||
Django Forms for interacting with Company app
|
|
||||||
"""
|
|
||||||
|
|
||||||
import django.forms
|
import django.forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -12,9 +10,7 @@ from .models import Company, SupplierPriceBreak
|
|||||||
|
|
||||||
|
|
||||||
class CompanyImageDownloadForm(HelperForm):
|
class CompanyImageDownloadForm(HelperForm):
|
||||||
"""
|
"""Form for downloading an image from a URL."""
|
||||||
Form for downloading an image from a URL
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = django.forms.URLField(
|
url = django.forms.URLField(
|
||||||
label=_('URL'),
|
label=_('URL'),
|
||||||
@ -30,7 +26,7 @@ class CompanyImageDownloadForm(HelperForm):
|
|||||||
|
|
||||||
|
|
||||||
class EditPriceBreakForm(HelperForm):
|
class EditPriceBreakForm(HelperForm):
|
||||||
""" Form for creating / editing a supplier price break """
|
"""Form for creating / editing a supplier price break."""
|
||||||
|
|
||||||
quantity = RoundingDecimalFormField(
|
quantity = RoundingDecimalFormField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Company database model definitions"""
|
"""Company database model definitions."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ from InvenTree.status_codes import PurchaseOrderStatus
|
|||||||
|
|
||||||
|
|
||||||
def rename_company_image(instance, filename):
|
def rename_company_image(instance, filename):
|
||||||
"""Function to rename a company image after upload
|
"""Function to rename a company image after upload.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance: Company object
|
instance: Company object
|
||||||
@ -50,7 +50,7 @@ def rename_company_image(instance, filename):
|
|||||||
|
|
||||||
|
|
||||||
class Company(models.Model):
|
class Company(models.Model):
|
||||||
""" A Company object represents an external company.
|
"""A Company object represents an external company.
|
||||||
|
|
||||||
It may be a supplier or a customer or a manufacturer (or a combination)
|
It may be a supplier or a customer or a manufacturer (or a combination)
|
||||||
|
|
||||||
@ -148,8 +148,7 @@ class Company(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def currency_code(self):
|
def currency_code(self):
|
||||||
"""
|
"""Return the currency code associated with this company.
|
||||||
Return the currency code associated with this company.
|
|
||||||
|
|
||||||
- If the currency code is invalid, use the default currency
|
- If the currency code is invalid, use the default currency
|
||||||
- If the currency code is not specified, use the default currency
|
- If the currency code is not specified, use the default currency
|
||||||
@ -162,22 +161,22 @@ class Company(models.Model):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
""" Get string representation of a Company """
|
"""Get string representation of a Company."""
|
||||||
return "{n} - {d}".format(n=self.name, d=self.description)
|
return "{n} - {d}".format(n=self.name, d=self.description)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
""" Get the web URL for the detail view for this Company """
|
"""Get the web URL for the detail view for this Company."""
|
||||||
return reverse('company-detail', kwargs={'pk': self.id})
|
return reverse('company-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
def get_image_url(self):
|
def get_image_url(self):
|
||||||
""" Return the URL of the image for this company """
|
"""Return the URL of the image for this company."""
|
||||||
if self.image:
|
if self.image:
|
||||||
return getMediaUrl(self.image.url)
|
return getMediaUrl(self.image.url)
|
||||||
else:
|
else:
|
||||||
return getBlankImage()
|
return getBlankImage()
|
||||||
|
|
||||||
def get_thumbnail_url(self):
|
def get_thumbnail_url(self):
|
||||||
""" Return the URL for the thumbnail image for this Company """
|
"""Return the URL for the thumbnail image for this Company."""
|
||||||
if self.image:
|
if self.image:
|
||||||
return getMediaUrl(self.image.thumbnail.url)
|
return getMediaUrl(self.image.thumbnail.url)
|
||||||
else:
|
else:
|
||||||
@ -185,7 +184,7 @@ class Company(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def manufactured_part_count(self):
|
def manufactured_part_count(self):
|
||||||
""" The number of parts manufactured by this company """
|
"""The number of parts manufactured by this company."""
|
||||||
return self.manufactured_parts.count()
|
return self.manufactured_parts.count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -194,22 +193,22 @@ class Company(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def supplied_part_count(self):
|
def supplied_part_count(self):
|
||||||
""" The number of parts supplied by this company """
|
"""The number of parts supplied by this company."""
|
||||||
return self.supplied_parts.count()
|
return self.supplied_parts.count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_supplied_parts(self):
|
def has_supplied_parts(self):
|
||||||
""" Return True if this company supplies any parts """
|
"""Return True if this company supplies any parts."""
|
||||||
return self.supplied_part_count > 0
|
return self.supplied_part_count > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parts(self):
|
def parts(self):
|
||||||
""" Return SupplierPart objects which are supplied or manufactured by this company """
|
"""Return SupplierPart objects which are supplied or manufactured by this company."""
|
||||||
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id))
|
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def part_count(self):
|
def part_count(self):
|
||||||
""" The number of parts manufactured (or supplied) by this Company """
|
"""The number of parts manufactured (or supplied) by this Company."""
|
||||||
return self.parts.count()
|
return self.parts.count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -218,25 +217,25 @@ class Company(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def stock_items(self):
|
def stock_items(self):
|
||||||
""" Return a list of all stock items supplied or manufactured by this company """
|
"""Return a list of all stock items supplied or manufactured by this company."""
|
||||||
stock = apps.get_model('stock', 'StockItem')
|
stock = apps.get_model('stock', 'StockItem')
|
||||||
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
|
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stock_count(self):
|
def stock_count(self):
|
||||||
""" Return the number of stock items supplied or manufactured by this company """
|
"""Return the number of stock items supplied or manufactured by this company."""
|
||||||
return self.stock_items.count()
|
return self.stock_items.count()
|
||||||
|
|
||||||
def outstanding_purchase_orders(self):
|
def outstanding_purchase_orders(self):
|
||||||
""" Return purchase orders which are 'outstanding' """
|
"""Return purchase orders which are 'outstanding'."""
|
||||||
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN)
|
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN)
|
||||||
|
|
||||||
def pending_purchase_orders(self):
|
def pending_purchase_orders(self):
|
||||||
""" Return purchase orders which are PENDING (not yet issued) """
|
"""Return purchase orders which are PENDING (not yet issued)"""
|
||||||
return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING)
|
return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING)
|
||||||
|
|
||||||
def closed_purchase_orders(self):
|
def closed_purchase_orders(self):
|
||||||
""" Return purchase orders which are not 'outstanding'
|
"""Return purchase orders which are not 'outstanding'.
|
||||||
|
|
||||||
- Complete
|
- Complete
|
||||||
- Failed / lost
|
- Failed / lost
|
||||||
@ -248,13 +247,12 @@ class Company(models.Model):
|
|||||||
return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE)
|
return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
def failed_purchase_orders(self):
|
def failed_purchase_orders(self):
|
||||||
""" Return any purchase orders which were not successful """
|
"""Return any purchase orders which were not successful."""
|
||||||
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
|
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
|
||||||
|
|
||||||
|
|
||||||
class Contact(models.Model):
|
class Contact(models.Model):
|
||||||
""" A Contact represents a person who works at a particular company.
|
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
|
||||||
A Company may have zero or more associated Contact objects.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
company: Company link for this contact
|
company: Company link for this contact
|
||||||
@ -277,10 +275,7 @@ class Contact(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPart(models.Model):
|
class ManufacturerPart(models.Model):
|
||||||
""" Represents a unique part as provided by a Manufacturer
|
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||||
Each ManufacturerPart is identified by a MPN (Manufacturer Part Number)
|
|
||||||
Each ManufacturerPart is also linked to a Part object.
|
|
||||||
A Part may be available from multiple manufacturers
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
part: Link to the master Part
|
part: Link to the master Part
|
||||||
@ -339,7 +334,7 @@ class ManufacturerPart(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, part, manufacturer, mpn, description, link=None):
|
def create(cls, part, manufacturer, mpn, description, link=None):
|
||||||
"""Check if ManufacturerPart instance does not already exist then create it"""
|
"""Check if ManufacturerPart instance does not already exist then create it."""
|
||||||
manufacturer_part = None
|
manufacturer_part = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -366,9 +361,7 @@ class ManufacturerPart(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachment(InvenTreeAttachment):
|
class ManufacturerPartAttachment(InvenTreeAttachment):
|
||||||
"""
|
"""Model for storing file attachments against a ManufacturerPart object."""
|
||||||
Model for storing file attachments against a ManufacturerPart object
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -382,8 +375,7 @@ class ManufacturerPartAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameter(models.Model):
|
class ManufacturerPartParameter(models.Model):
|
||||||
"""
|
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||||
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
|
||||||
|
|
||||||
This is used to represent parmeters / properties for a particular manufacturer part.
|
This is used to represent parmeters / properties for a particular manufacturer part.
|
||||||
|
|
||||||
@ -427,10 +419,10 @@ class ManufacturerPartParameter(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPartManager(models.Manager):
|
class SupplierPartManager(models.Manager):
|
||||||
""" Define custom SupplierPart objects manager
|
"""Define custom SupplierPart objects manager.
|
||||||
|
|
||||||
The main purpose of this manager is to improve database hit as the
|
The main purpose of this manager is to improve database hit as the
|
||||||
SupplierPart model involves A LOT of foreign keys lookups
|
SupplierPart model involves A LOT of foreign keys lookups
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -443,10 +435,7 @@ class SupplierPartManager(models.Manager):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPart(models.Model):
|
class SupplierPart(models.Model):
|
||||||
""" Represents a unique part as provided by a Supplier
|
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
|
||||||
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
|
||||||
Each SupplierPart is also linked to a Part or ManufacturerPart object.
|
|
||||||
A Part may be available from multiple suppliers
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
part: Link to the master Part (Obsolete)
|
part: Link to the master Part (Obsolete)
|
||||||
@ -498,7 +487,7 @@ class SupplierPart(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" Overriding save method to connect an existing ManufacturerPart """
|
"""Overriding save method to connect an existing ManufacturerPart."""
|
||||||
manufacturer_part = None
|
manufacturer_part = None
|
||||||
|
|
||||||
if all(key in kwargs for key in ('manufacturer', 'MPN')):
|
if all(key in kwargs for key in ('manufacturer', 'MPN')):
|
||||||
@ -602,7 +591,7 @@ class SupplierPart(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def price_breaks(self):
|
def price_breaks(self):
|
||||||
""" Return the associated price breaks in the correct order """
|
"""Return the associated price breaks in the correct order."""
|
||||||
return self.pricebreaks.order_by('quantity').all()
|
return self.pricebreaks.order_by('quantity').all()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -610,9 +599,9 @@ class SupplierPart(models.Model):
|
|||||||
return self.get_price(1)
|
return self.get_price(1)
|
||||||
|
|
||||||
def add_price_break(self, quantity, price):
|
def add_price_break(self, quantity, price):
|
||||||
"""Create a new price break for this part
|
"""Create a new price break for this part.
|
||||||
|
|
||||||
args:
|
Args:
|
||||||
quantity - Numerical quantity
|
quantity - Numerical quantity
|
||||||
price - Must be a Money object
|
price - Must be a Money object
|
||||||
"""
|
"""
|
||||||
@ -651,7 +640,7 @@ class SupplierPart(models.Model):
|
|||||||
return max(q - r, 0)
|
return max(q - r, 0)
|
||||||
|
|
||||||
def purchase_orders(self):
|
def purchase_orders(self):
|
||||||
"""Returns a list of purchase orders relating to this supplier part"""
|
"""Returns a list of purchase orders relating to this supplier part."""
|
||||||
return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')]
|
return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -675,6 +664,7 @@ class SupplierPart(models.Model):
|
|||||||
|
|
||||||
class SupplierPriceBreak(common.models.PriceBreak):
|
class SupplierPriceBreak(common.models.PriceBreak):
|
||||||
"""Represents a quantity price break for a SupplierPart.
|
"""Represents a quantity price break for a SupplierPart.
|
||||||
|
|
||||||
- Suppliers can offer discounts at larger quantities
|
- Suppliers can offer discounts at larger quantities
|
||||||
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
|
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""JSON serializers for Company app."""
|
||||||
JSON serializers for Company app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -21,7 +19,7 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
|||||||
|
|
||||||
|
|
||||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for Company object (limited detail) """
|
"""Serializer for Company object (limited detail)"""
|
||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
|
|
||||||
@ -39,7 +37,7 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class CompanySerializer(InvenTreeModelSerializer):
|
class CompanySerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for Company object (full detail) """
|
"""Serializer for Company object (full detail)"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
@ -96,9 +94,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializer for ManufacturerPart object."""
|
||||||
Serializer for ManufacturerPart object
|
|
||||||
"""
|
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
@ -141,9 +137,7 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""Serializer for the ManufacturerPartAttachment class."""
|
||||||
Serializer for the ManufacturerPartAttachment class
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ManufacturerPartAttachment
|
model = ManufacturerPartAttachment
|
||||||
@ -164,9 +158,7 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializer for the ManufacturerPartParameter model."""
|
||||||
Serializer for the ManufacturerPartParameter model
|
|
||||||
"""
|
|
||||||
|
|
||||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
||||||
|
|
||||||
@ -193,7 +185,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for SupplierPart object """
|
"""Serializer for SupplierPart object."""
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
@ -255,8 +247,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
""" Extract manufacturer data and process ManufacturerPart """
|
"""Extract manufacturer data and process ManufacturerPart."""
|
||||||
|
|
||||||
# Create SupplierPart
|
# Create SupplierPart
|
||||||
supplier_part = super().create(validated_data)
|
supplier_part = super().create(validated_data)
|
||||||
|
|
||||||
@ -275,7 +266,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for SupplierPriceBreak object """
|
"""Serializer for SupplierPriceBreak object."""
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
quantity = InvenTreeDecimalField()
|
||||||
|
|
||||||
|
@ -8,9 +8,7 @@ from .models import Company
|
|||||||
|
|
||||||
|
|
||||||
class CompanyTest(InvenTreeAPITestCase):
|
class CompanyTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""Series of tests for the Company DRF API."""
|
||||||
Series of tests for the Company DRF API
|
|
||||||
"""
|
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
'purchase_order.add',
|
'purchase_order.add',
|
||||||
@ -45,10 +43,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
def test_company_detail(self):
|
def test_company_detail(self):
|
||||||
"""
|
"""Tests for the Company detail endpoint."""
|
||||||
Tests for the Company detail endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
|
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
@ -71,20 +66,14 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.data['currency'], 'NZD')
|
self.assertEqual(response.data['currency'], 'NZD')
|
||||||
|
|
||||||
def test_company_search(self):
|
def test_company_search(self):
|
||||||
"""
|
"""Test search functionality in company list."""
|
||||||
Test search functionality in company list
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-company-list')
|
url = reverse('api-company-list')
|
||||||
data = {'search': 'cup'}
|
data = {'search': 'cup'}
|
||||||
response = self.get(url, data)
|
response = self.get(url, data)
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
def test_company_create(self):
|
def test_company_create(self):
|
||||||
"""
|
"""Test that we can create a company via the API!"""
|
||||||
Test that we can create a company via the API!
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = reverse('api-company-list')
|
url = reverse('api-company-list')
|
||||||
|
|
||||||
# Name is required
|
# Name is required
|
||||||
@ -146,9 +135,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerTest(InvenTreeAPITestCase):
|
class ManufacturerTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""Series of tests for the Manufacturer DRF API."""
|
||||||
Series of tests for the Manufacturer DRF API
|
|
||||||
"""
|
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
@ -191,9 +178,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
def test_manufacturer_part_detail(self):
|
def test_manufacturer_part_detail(self):
|
||||||
"""
|
"""Tests for the ManufacturerPart detail endpoint."""
|
||||||
Tests for the ManufacturerPart detail endpoint
|
|
||||||
"""
|
|
||||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': 1})
|
url = reverse('api-manufacturer-part-detail', kwargs={'pk': 1})
|
||||||
|
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Tests for the company model database migrations."""
|
||||||
Tests for the company model database migrations
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
@ -13,10 +11,7 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
migrate_to = ('company', helpers.getNewestMigrationFile('company'))
|
migrate_to = ('company', helpers.getNewestMigrationFile('company'))
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Create some simple Company data, and ensure that it migrates OK."""
|
||||||
Create some simple Company data, and ensure that it migrates OK
|
|
||||||
"""
|
|
||||||
|
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
Company.objects.create(
|
Company.objects.create(
|
||||||
@ -33,22 +28,18 @@ class TestForwardMigrations(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestManufacturerField(MigratorTestCase):
|
class TestManufacturerField(MigratorTestCase):
|
||||||
"""
|
"""Tests for migration 0019 which migrates from old 'manufacturer_name' field to new 'manufacturer' field."""
|
||||||
Tests for migration 0019 which migrates from old 'manufacturer_name' field to new 'manufacturer' field
|
|
||||||
"""
|
|
||||||
|
|
||||||
migrate_from = ('company', '0018_supplierpart_manufacturer')
|
migrate_from = ('company', '0018_supplierpart_manufacturer')
|
||||||
migrate_to = ('company', '0019_auto_20200413_0642')
|
migrate_to = ('company', '0019_auto_20200413_0642')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Prepare the database by adding some test data 'before' the change:
|
||||||
Prepare the database by adding some test data 'before' the change:
|
|
||||||
|
|
||||||
- Part object
|
- Part object
|
||||||
- Company object (supplier)
|
- Company object (supplier)
|
||||||
- SupplierPart object
|
- SupplierPart object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Part = self.old_state.apps.get_model('part', 'part')
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||||
@ -85,10 +76,7 @@ class TestManufacturerField(MigratorTestCase):
|
|||||||
self.assertEqual(Company.objects.count(), 1)
|
self.assertEqual(Company.objects.count(), 1)
|
||||||
|
|
||||||
def test_company_objects(self):
|
def test_company_objects(self):
|
||||||
"""
|
"""Test that the new companies have been created successfully."""
|
||||||
Test that the new companies have been created successfully
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Two additional company objects should have been created
|
# Two additional company objects should have been created
|
||||||
Company = self.new_state.apps.get_model('company', 'company')
|
Company = self.new_state.apps.get_model('company', 'company')
|
||||||
self.assertEqual(Company.objects.count(), 3)
|
self.assertEqual(Company.objects.count(), 3)
|
||||||
@ -108,22 +96,18 @@ class TestManufacturerField(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestManufacturerPart(MigratorTestCase):
|
class TestManufacturerPart(MigratorTestCase):
|
||||||
"""
|
"""Tests for migration 0034-0037 which added and transitioned to the ManufacturerPart model."""
|
||||||
Tests for migration 0034-0037 which added and transitioned to the ManufacturerPart model
|
|
||||||
"""
|
|
||||||
|
|
||||||
migrate_from = ('company', '0033_auto_20210410_1528')
|
migrate_from = ('company', '0033_auto_20210410_1528')
|
||||||
migrate_to = ('company', '0037_supplierpart_update_3')
|
migrate_to = ('company', '0037_supplierpart_update_3')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Prepare the database by adding some test data 'before' the change:
|
||||||
Prepare the database by adding some test data 'before' the change:
|
|
||||||
|
|
||||||
- Part object
|
- Part object
|
||||||
- Company object (supplier)
|
- Company object (supplier)
|
||||||
- SupplierPart object
|
- SupplierPart object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Part = self.old_state.apps.get_model('part', 'part')
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||||
@ -214,10 +198,7 @@ class TestManufacturerPart(MigratorTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_manufacturer_part_objects(self):
|
def test_manufacturer_part_objects(self):
|
||||||
"""
|
"""Test that the new companies have been created successfully."""
|
||||||
Test that the new companies have been created successfully
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Check on the SupplierPart objects
|
# Check on the SupplierPart objects
|
||||||
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
|
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
|
||||||
|
|
||||||
@ -238,16 +219,13 @@ class TestManufacturerPart(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestCurrencyMigration(MigratorTestCase):
|
class TestCurrencyMigration(MigratorTestCase):
|
||||||
"""
|
"""Tests for upgrade from basic currency support to django-money."""
|
||||||
Tests for upgrade from basic currency support to django-money
|
|
||||||
"""
|
|
||||||
|
|
||||||
migrate_from = ('company', '0025_auto_20201110_1001')
|
migrate_from = ('company', '0025_auto_20201110_1001')
|
||||||
migrate_to = ('company', '0026_auto_20201110_1011')
|
migrate_to = ('company', '0026_auto_20201110_1011')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""Prepare some data:
|
||||||
Prepare some data:
|
|
||||||
|
|
||||||
- A part to buy
|
- A part to buy
|
||||||
- A supplier to buy from
|
- A supplier to buy from
|
||||||
@ -255,7 +233,6 @@ class TestCurrencyMigration(MigratorTestCase):
|
|||||||
- Multiple currency objects
|
- Multiple currency objects
|
||||||
- Multiple supplier price breaks
|
- Multiple supplier price breaks
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Part = self.old_state.apps.get_model('part', 'part')
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
part = Part.objects.create(
|
part = Part.objects.create(
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
""" Unit tests for Company views (see views.py) """
|
"""Unit tests for Company views (see views.py)"""
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@ -20,38 +20,31 @@ class CompanyViewTestBase(InvenTreeTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CompanyViewTest(CompanyViewTestBase):
|
class CompanyViewTest(CompanyViewTestBase):
|
||||||
"""
|
"""Tests for various 'Company' views."""
|
||||||
Tests for various 'Company' views
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_company_index(self):
|
def test_company_index(self):
|
||||||
""" Test the company index """
|
"""Test the company index."""
|
||||||
|
|
||||||
response = self.client.get(reverse('company-index'))
|
response = self.client.get(reverse('company-index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_manufacturer_index(self):
|
def test_manufacturer_index(self):
|
||||||
""" Test the manufacturer index """
|
"""Test the manufacturer index."""
|
||||||
|
|
||||||
response = self.client.get(reverse('manufacturer-index'))
|
response = self.client.get(reverse('manufacturer-index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_customer_index(self):
|
def test_customer_index(self):
|
||||||
""" Test the customer index """
|
"""Test the customer index."""
|
||||||
|
|
||||||
response = self.client.get(reverse('customer-index'))
|
response = self.client.get(reverse('customer-index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_manufacturer_part_detail_view(self):
|
def test_manufacturer_part_detail_view(self):
|
||||||
""" Test the manufacturer part detail view """
|
"""Test the manufacturer part detail view."""
|
||||||
|
|
||||||
response = self.client.get(reverse('manufacturer-part-detail', kwargs={'pk': 1}))
|
response = self.client.get(reverse('manufacturer-part-detail', kwargs={'pk': 1}))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'MPN123')
|
self.assertContains(response, 'MPN123')
|
||||||
|
|
||||||
def test_supplier_part_detail_view(self):
|
def test_supplier_part_detail_view(self):
|
||||||
""" Test the supplier part detail view """
|
"""Test the supplier part detail view."""
|
||||||
|
|
||||||
response = self.client.get(reverse('supplier-part-detail', kwargs={'pk': 10}))
|
response = self.client.get(reverse('supplier-part-detail', kwargs={'pk': 10}))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'MPN456-APPEL')
|
self.assertContains(response, 'MPN456-APPEL')
|
||||||
|
@ -81,8 +81,7 @@ class CompanySimpleTest(TestCase):
|
|||||||
self.assertEqual(self.zergm312.price_breaks.count(), 2)
|
self.assertEqual(self.zergm312.price_breaks.count(), 2)
|
||||||
|
|
||||||
def test_quantity_pricing(self):
|
def test_quantity_pricing(self):
|
||||||
""" Simple test for quantity pricing """
|
"""Simple test for quantity pricing."""
|
||||||
|
|
||||||
p = self.acme0001.get_price
|
p = self.acme0001.get_price
|
||||||
self.assertEqual(p(1), 10)
|
self.assertEqual(p(1), 10)
|
||||||
self.assertEqual(p(4), 40)
|
self.assertEqual(p(4), 40)
|
||||||
@ -116,10 +115,7 @@ class CompanySimpleTest(TestCase):
|
|||||||
self.assertIsNotNone(m3x12.get_price_info(50))
|
self.assertIsNotNone(m3x12.get_price_info(50))
|
||||||
|
|
||||||
def test_currency_validation(self):
|
def test_currency_validation(self):
|
||||||
"""
|
"""Test validation for currency selection."""
|
||||||
Test validation for currency selection
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Create a company with a valid currency code (should pass)
|
# Create a company with a valid currency code (should pass)
|
||||||
company = Company.objects.create(
|
company = Company.objects.create(
|
||||||
name='Test',
|
name='Test',
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""URL lookup for Company app."""
|
||||||
URL lookup for Company app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django views for interacting with Company app."""
|
||||||
Django views for interacting with Company app
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@ -20,8 +18,7 @@ from .models import Company, ManufacturerPart, SupplierPart
|
|||||||
|
|
||||||
|
|
||||||
class CompanyIndex(InvenTreeRoleMixin, ListView):
|
class CompanyIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" View for displaying list of companies
|
"""View for displaying list of companies."""
|
||||||
"""
|
|
||||||
|
|
||||||
model = Company
|
model = Company
|
||||||
template_name = 'company/index.html'
|
template_name = 'company/index.html'
|
||||||
@ -80,7 +77,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Retrieve the Company queryset based on HTTP request parameters.
|
"""Retrieve the Company queryset based on HTTP request parameters.
|
||||||
|
|
||||||
- supplier: Filter by supplier
|
- supplier: Filter by supplier
|
||||||
- customer: Filter by customer
|
- customer: Filter by customer
|
||||||
@ -97,7 +94,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
|
|||||||
|
|
||||||
|
|
||||||
class CompanyDetail(InvenTreePluginViewMixin, DetailView):
|
class CompanyDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for Company object """
|
"""Detail view for Company object."""
|
||||||
context_obect_name = 'company'
|
context_obect_name = 'company'
|
||||||
template_name = 'company/detail.html'
|
template_name = 'company/detail.html'
|
||||||
queryset = Company.objects.all()
|
queryset = Company.objects.all()
|
||||||
@ -111,9 +108,7 @@ class CompanyDetail(InvenTreePluginViewMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class CompanyImageDownloadFromURL(AjaxUpdateView):
|
class CompanyImageDownloadFromURL(AjaxUpdateView):
|
||||||
"""
|
"""View for downloading an image from a provided URL."""
|
||||||
View for downloading an image from a provided URL
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Company
|
model = Company
|
||||||
ajax_template_name = 'image_download.html'
|
ajax_template_name = 'image_download.html'
|
||||||
@ -121,9 +116,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Download Image')
|
ajax_form_title = _('Download Image')
|
||||||
|
|
||||||
def validate(self, company, form):
|
def validate(self, company, form):
|
||||||
"""
|
"""Validate that the image data are correct."""
|
||||||
Validate that the image data are correct
|
|
||||||
"""
|
|
||||||
# First ensure that the normal validation routines pass
|
# First ensure that the normal validation routines pass
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
return
|
return
|
||||||
@ -167,9 +160,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
return
|
return
|
||||||
|
|
||||||
def save(self, company, form, **kwargs):
|
def save(self, company, form, **kwargs):
|
||||||
"""
|
"""Save the downloaded image to the company."""
|
||||||
Save the downloaded image to the company
|
|
||||||
"""
|
|
||||||
fmt = self.image.format
|
fmt = self.image.format
|
||||||
|
|
||||||
if not fmt:
|
if not fmt:
|
||||||
@ -189,7 +180,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
|
class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for ManufacturerPart """
|
"""Detail view for ManufacturerPart."""
|
||||||
model = ManufacturerPart
|
model = ManufacturerPart
|
||||||
template_name = 'company/manufacturer_part_detail.html'
|
template_name = 'company/manufacturer_part_detail.html'
|
||||||
context_object_name = 'part'
|
context_object_name = 'part'
|
||||||
@ -203,7 +194,7 @@ class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for SupplierPart """
|
"""Detail view for SupplierPart."""
|
||||||
model = SupplierPart
|
model = SupplierPart
|
||||||
template_name = 'company/supplier_part_detail.html'
|
template_name = 'company/supplier_part_detail.html'
|
||||||
context_object_name = 'part'
|
context_object_name = 'part'
|
||||||
|
@ -24,9 +24,7 @@ from .serializers import (PartLabelSerializer, StockItemLabelSerializer,
|
|||||||
|
|
||||||
|
|
||||||
class LabelListView(generics.ListAPIView):
|
class LabelListView(generics.ListAPIView):
|
||||||
"""
|
"""Generic API class for label templates."""
|
||||||
Generic API class for label templates
|
|
||||||
"""
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
@ -44,14 +42,10 @@ class LabelListView(generics.ListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class LabelPrintMixin:
|
class LabelPrintMixin:
|
||||||
"""
|
"""Mixin for printing labels."""
|
||||||
Mixin for printing labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_plugin(self, request):
|
def get_plugin(self, request):
|
||||||
"""
|
"""Return the label printing plugin associated with this request. This is provided in the url, e.g. ?plugin=myprinter.
|
||||||
Return the label printing plugin associated with this request.
|
|
||||||
This is provided in the url, e.g. ?plugin=myprinter
|
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
- settings.PLUGINS_ENABLED is True
|
- settings.PLUGINS_ENABLED is True
|
||||||
@ -59,7 +53,6 @@ class LabelPrintMixin:
|
|||||||
- matching plugin implements the 'labels' mixin
|
- matching plugin implements the 'labels' mixin
|
||||||
- matching plugin is enabled
|
- matching plugin is enabled
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not settings.PLUGINS_ENABLED:
|
if not settings.PLUGINS_ENABLED:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
@ -83,10 +76,7 @@ class LabelPrintMixin:
|
|||||||
raise NotFound(f"Plugin '{plugin_key}' not found")
|
raise NotFound(f"Plugin '{plugin_key}' not found")
|
||||||
|
|
||||||
def print(self, request, items_to_print):
|
def print(self, request, items_to_print):
|
||||||
"""
|
"""Print this label template against a number of pre-validated items."""
|
||||||
Print this label template against a number of pre-validated items
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Check the request to determine if the user has selected a label printing plugin
|
# Check the request to determine if the user has selected a label printing plugin
|
||||||
plugin = self.get_plugin(request)
|
plugin = self.get_plugin(request)
|
||||||
|
|
||||||
@ -123,25 +113,20 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
if plugin is not None:
|
if plugin is not None:
|
||||||
"""
|
"""
|
||||||
Label printing is to be handled by a plugin,
|
Label printing is to be handled by a plugin, rather than being exported to PDF.
|
||||||
rather than being exported to PDF.
|
|
||||||
|
|
||||||
In this case, we do the following:
|
In this case, we do the following:
|
||||||
|
|
||||||
- Individually generate each label, exporting as an image file
|
- Individually generate each label, exporting as an image file
|
||||||
- Pass all the images through to the label printing plugin
|
- Pass all the images through to the label printing plugin
|
||||||
- Return a JSON response indicating that the printing has been offloaded
|
- Return a JSON response indicating that the printing has been offloaded
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Label instance
|
# Label instance
|
||||||
label_instance = self.get_object()
|
label_instance = self.get_object()
|
||||||
|
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
"""
|
"""For each output, we generate a temporary image file, which will then get sent to the printer."""
|
||||||
For each output, we generate a temporary image file,
|
|
||||||
which will then get sent to the printer
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Generate a png image at 300dpi
|
# Generate a png image at 300dpi
|
||||||
(img_data, w, h) = output.get_document().write_png(resolution=300)
|
(img_data, w, h) = output.get_document().write_png(resolution=300)
|
||||||
@ -166,20 +151,14 @@ class LabelPrintMixin:
|
|||||||
})
|
})
|
||||||
|
|
||||||
elif debug_mode:
|
elif debug_mode:
|
||||||
"""
|
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
|
||||||
Contatenate all rendered templates into a single HTML string,
|
|
||||||
and return the string as a HTML response.
|
|
||||||
"""
|
|
||||||
|
|
||||||
html = "\n".join(outputs)
|
html = "\n".join(outputs)
|
||||||
|
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
"""
|
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
|
||||||
Concatenate all rendered pages into a single PDF object,
|
|
||||||
and return the resulting document!
|
|
||||||
"""
|
|
||||||
|
|
||||||
pages = []
|
pages = []
|
||||||
|
|
||||||
@ -205,15 +184,10 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelMixin:
|
class StockItemLabelMixin:
|
||||||
"""
|
"""Mixin for extracting stock items from query params."""
|
||||||
Mixin for extracting stock items from query params
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_items(self):
|
def get_items(self):
|
||||||
"""
|
"""Return a list of requested stock items."""
|
||||||
Return a list of requested stock items
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -238,25 +212,20 @@ class StockItemLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||||
"""
|
"""API endpoint for viewing list of StockItemLabel objects.
|
||||||
API endpoint for viewing list of StockItemLabel objects.
|
|
||||||
|
|
||||||
Filterable by:
|
Filterable by:
|
||||||
|
|
||||||
- enabled: Filter by enabled / disabled status
|
- enabled: Filter by enabled / disabled status
|
||||||
- item: Filter by single stock item
|
- item: Filter by single stock item
|
||||||
- items: Filter by list of stock items
|
- items: Filter by list of stock items
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Filter the StockItem label queryset."""
|
||||||
Filter the StockItem label queryset.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
# List of StockItem objects to match against
|
# List of StockItem objects to match against
|
||||||
@ -265,9 +234,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
|||||||
# We wish to filter by stock items
|
# We wish to filter by stock items
|
||||||
if len(items) > 0:
|
if len(items) > 0:
|
||||||
"""
|
"""
|
||||||
At this point, we are basically forced to be inefficient,
|
At this point, we are basically forced to be inefficient, as we need to compare the 'filters' string of each label, and see if it matches against each of the requested items.
|
||||||
as we need to compare the 'filters' string of each label,
|
|
||||||
and see if it matches against each of the requested items.
|
|
||||||
|
|
||||||
TODO: In the future, if this becomes excessively slow, it
|
TODO: In the future, if this becomes excessively slow, it
|
||||||
will need to be readdressed.
|
will need to be readdressed.
|
||||||
@ -311,42 +278,30 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockItemLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for a single StockItemLabel object."""
|
||||||
API endpoint for a single StockItemLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
class StockItemLabelPrint(generics.RetrieveAPIView, StockItemLabelMixin, LabelPrintMixin):
|
||||||
"""
|
"""API endpoint for printing a StockItemLabel object."""
|
||||||
API endpoint for printing a StockItemLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = StockItemLabel.objects.all()
|
queryset = StockItemLabel.objects.all()
|
||||||
serializer_class = StockItemLabelSerializer
|
serializer_class = StockItemLabelSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""
|
"""Check if valid stock item(s) have been provided."""
|
||||||
Check if valid stock item(s) have been provided.
|
|
||||||
"""
|
|
||||||
|
|
||||||
items = self.get_items()
|
items = self.get_items()
|
||||||
|
|
||||||
return self.print(request, items)
|
return self.print(request, items)
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabelMixin:
|
class StockLocationLabelMixin:
|
||||||
"""
|
"""Mixin for extracting stock locations from query params."""
|
||||||
Mixin for extracting stock locations from query params
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_locations(self):
|
def get_locations(self):
|
||||||
"""
|
"""Return a list of requested stock locations."""
|
||||||
Return a list of requested stock locations
|
|
||||||
"""
|
|
||||||
|
|
||||||
locations = []
|
locations = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -371,8 +326,7 @@ class StockLocationLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||||
"""
|
"""API endpoint for viewiing list of StockLocationLabel objects.
|
||||||
API endpoint for viewiing list of StockLocationLabel objects.
|
|
||||||
|
|
||||||
Filterable by:
|
Filterable by:
|
||||||
|
|
||||||
@ -385,10 +339,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
serializer_class = StockLocationLabelSerializer
|
serializer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Filter the StockLocationLabel queryset."""
|
||||||
Filter the StockLocationLabel queryset
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
# List of StockLocation objects to match against
|
# List of StockLocation objects to match against
|
||||||
@ -397,9 +348,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
# We wish to filter by stock location(s)
|
# We wish to filter by stock location(s)
|
||||||
if len(locations) > 0:
|
if len(locations) > 0:
|
||||||
"""
|
"""
|
||||||
At this point, we are basically forced to be inefficient,
|
At this point, we are basically forced to be inefficient, as we need to compare the 'filters' string of each label, and see if it matches against each of the requested items.
|
||||||
as we need to compare the 'filters' string of each label,
|
|
||||||
and see if it matches against each of the requested items.
|
|
||||||
|
|
||||||
TODO: In the future, if this becomes excessively slow, it
|
TODO: In the future, if this becomes excessively slow, it
|
||||||
will need to be readdressed.
|
will need to be readdressed.
|
||||||
@ -443,18 +392,14 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockLocationLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for a single StockLocationLabel object."""
|
||||||
API endpoint for a single StockLocationLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = StockLocationLabel.objects.all()
|
queryset = StockLocationLabel.objects.all()
|
||||||
serializer_class = StockLocationLabelSerializer
|
serializer_class = StockLocationLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin, LabelPrintMixin):
|
||||||
"""
|
"""API endpoint for printing a StockLocationLabel object."""
|
||||||
API endpoint for printing a StockLocationLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = StockLocationLabel.objects.all()
|
queryset = StockLocationLabel.objects.all()
|
||||||
seiralizer_class = StockLocationLabelSerializer
|
seiralizer_class = StockLocationLabelSerializer
|
||||||
@ -467,15 +412,10 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelMixin:
|
class PartLabelMixin:
|
||||||
"""
|
"""Mixin for extracting Part objects from query parameters."""
|
||||||
Mixin for extracting Part objects from query parameters
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_parts(self):
|
def get_parts(self):
|
||||||
"""
|
"""Return a list of requested Part objects."""
|
||||||
Return a list of requested Part objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -498,9 +438,7 @@ class PartLabelMixin:
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelList(LabelListView, PartLabelMixin):
|
class PartLabelList(LabelListView, PartLabelMixin):
|
||||||
"""
|
"""API endpoint for viewing list of PartLabel objects."""
|
||||||
API endpoint for viewing list of PartLabel objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
@ -546,27 +484,20 @@ class PartLabelList(LabelListView, PartLabelMixin):
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for a single PartLabel object."""
|
||||||
API endpoint for a single PartLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
||||||
"""
|
"""API endpoint for printing a PartLabel object."""
|
||||||
API endpoint for printing a PartLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = PartLabel.objects.all()
|
queryset = PartLabel.objects.all()
|
||||||
serializer_class = PartLabelSerializer
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""
|
"""Check if valid part(s) have been provided."""
|
||||||
Check if valid part(s) have been provided
|
|
||||||
"""
|
|
||||||
|
|
||||||
parts = self.get_parts()
|
parts = self.get_parts()
|
||||||
|
|
||||||
return self.print(request, parts)
|
return self.print(request, parts)
|
||||||
|
@ -14,10 +14,7 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def hashFile(filename):
|
def hashFile(filename):
|
||||||
"""
|
"""Calculate the MD5 hash of a file."""
|
||||||
Calculate the MD5 hash of a file
|
|
||||||
"""
|
|
||||||
|
|
||||||
md5 = hashlib.md5()
|
md5 = hashlib.md5()
|
||||||
|
|
||||||
with open(filename, 'rb') as f:
|
with open(filename, 'rb') as f:
|
||||||
@ -31,17 +28,12 @@ class LabelConfig(AppConfig):
|
|||||||
name = 'label'
|
name = 'label'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""
|
"""This function is called whenever the label app is loaded."""
|
||||||
This function is called whenever the label app is loaded
|
|
||||||
"""
|
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase():
|
||||||
self.create_labels() # pragma: no cover
|
self.create_labels() # pragma: no cover
|
||||||
|
|
||||||
def create_labels(self):
|
def create_labels(self):
|
||||||
"""
|
"""Create all default templates."""
|
||||||
Create all default templates
|
|
||||||
"""
|
|
||||||
# Test if models are ready
|
# Test if models are ready
|
||||||
try:
|
try:
|
||||||
from .models import StockLocationLabel
|
from .models import StockLocationLabel
|
||||||
@ -56,11 +48,7 @@ class LabelConfig(AppConfig):
|
|||||||
self.create_part_labels()
|
self.create_part_labels()
|
||||||
|
|
||||||
def create_stock_item_labels(self):
|
def create_stock_item_labels(self):
|
||||||
"""
|
"""Create database entries for the default StockItemLabel templates, if they do not already exist."""
|
||||||
Create database entries for the default StockItemLabel templates,
|
|
||||||
if they do not already exist
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .models import StockItemLabel
|
from .models import StockItemLabel
|
||||||
|
|
||||||
src_dir = os.path.join(
|
src_dir = os.path.join(
|
||||||
@ -139,11 +127,7 @@ class LabelConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def create_stock_location_labels(self):
|
def create_stock_location_labels(self):
|
||||||
"""
|
"""Create database entries for the default StockItemLocation templates, if they do not already exist."""
|
||||||
Create database entries for the default StockItemLocation templates,
|
|
||||||
if they do not already exist
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .models import StockLocationLabel
|
from .models import StockLocationLabel
|
||||||
|
|
||||||
src_dir = os.path.join(
|
src_dir = os.path.join(
|
||||||
@ -229,11 +213,7 @@ class LabelConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def create_part_labels(self):
|
def create_part_labels(self):
|
||||||
"""
|
"""Create database entries for the default PartLabel templates, if they do not already exist."""
|
||||||
Create database entries for the default PartLabel templates,
|
|
||||||
if they do not already exist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .models import PartLabel
|
from .models import PartLabel
|
||||||
|
|
||||||
src_dir = os.path.join(
|
src_dir = os.path.join(
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Label printing models."""
|
||||||
Label printing models
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
@ -32,8 +30,7 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
def rename_label(instance, filename):
|
def rename_label(instance, filename):
|
||||||
""" Place the label file into the correct subdirectory """
|
"""Place the label file into the correct subdirectory."""
|
||||||
|
|
||||||
filename = os.path.basename(filename)
|
filename = os.path.basename(filename)
|
||||||
|
|
||||||
return os.path.join('label', 'template', instance.SUBDIR, filename)
|
return os.path.join('label', 'template', instance.SUBDIR, filename)
|
||||||
@ -61,9 +58,7 @@ def validate_part_filters(filters):
|
|||||||
|
|
||||||
|
|
||||||
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
||||||
"""
|
"""Class for rendering a label to a PDF."""
|
||||||
Class for rendering a label to a PDF
|
|
||||||
"""
|
|
||||||
|
|
||||||
pdf_filename = 'label.pdf'
|
pdf_filename = 'label.pdf'
|
||||||
pdf_attachment = True
|
pdf_attachment = True
|
||||||
@ -76,9 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
|||||||
|
|
||||||
|
|
||||||
class LabelTemplate(models.Model):
|
class LabelTemplate(models.Model):
|
||||||
"""
|
"""Base class for generic, filterable labels."""
|
||||||
Base class for generic, filterable labels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@ -150,11 +143,10 @@ class LabelTemplate(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def template_name(self):
|
def template_name(self):
|
||||||
"""
|
"""Returns the file system path to the template file.
|
||||||
Returns the file system path to the template file.
|
|
||||||
Required for passing the file to an external process
|
Required for passing the file to an external process
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = self.label.name
|
template = self.label.name
|
||||||
template = template.replace('/', os.path.sep)
|
template = template.replace('/', os.path.sep)
|
||||||
template = template.replace('\\', os.path.sep)
|
template = template.replace('\\', os.path.sep)
|
||||||
@ -164,19 +156,14 @@ class LabelTemplate(models.Model):
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""Supply custom context data to the template for rendering.
|
||||||
Supply custom context data to the template for rendering.
|
|
||||||
|
|
||||||
Note: Override this in any subclass
|
Note: Override this in any subclass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return {} # pragma: no cover
|
return {} # pragma: no cover
|
||||||
|
|
||||||
def generate_filename(self, request, **kwargs):
|
def generate_filename(self, request, **kwargs):
|
||||||
"""
|
"""Generate a filename for this label."""
|
||||||
Generate a filename for this label
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_string = Template(self.filename_pattern)
|
template_string = Template(self.filename_pattern)
|
||||||
|
|
||||||
ctx = self.context(request)
|
ctx = self.context(request)
|
||||||
@ -186,10 +173,7 @@ class LabelTemplate(models.Model):
|
|||||||
return template_string.render(context)
|
return template_string.render(context)
|
||||||
|
|
||||||
def context(self, request):
|
def context(self, request):
|
||||||
"""
|
"""Provides context data to the template."""
|
||||||
Provides context data to the template.
|
|
||||||
"""
|
|
||||||
|
|
||||||
context = self.get_context_data(request)
|
context = self.get_context_data(request)
|
||||||
|
|
||||||
# Add "basic" context data which gets passed to every label
|
# Add "basic" context data which gets passed to every label
|
||||||
@ -204,21 +188,17 @@ class LabelTemplate(models.Model):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def render_as_string(self, request, **kwargs):
|
def render_as_string(self, request, **kwargs):
|
||||||
"""
|
"""Render the label to a HTML string.
|
||||||
Render the label to a HTML string
|
|
||||||
|
|
||||||
Useful for debug mode (viewing generated code)
|
Useful for debug mode (viewing generated code)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return render_to_string(self.template_name, self.context(request), request)
|
return render_to_string(self.template_name, self.context(request), request)
|
||||||
|
|
||||||
def render(self, request, **kwargs):
|
def render(self, request, **kwargs):
|
||||||
"""
|
"""Render the label template to a PDF file.
|
||||||
Render the label template to a PDF file
|
|
||||||
|
|
||||||
Uses django-weasyprint plugin to render HTML template
|
Uses django-weasyprint plugin to render HTML template
|
||||||
"""
|
"""
|
||||||
|
|
||||||
wp = WeasyprintLabelMixin(
|
wp = WeasyprintLabelMixin(
|
||||||
request,
|
request,
|
||||||
self.template_name,
|
self.template_name,
|
||||||
@ -235,9 +215,7 @@ class LabelTemplate(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabel(LabelTemplate):
|
class StockItemLabel(LabelTemplate):
|
||||||
"""
|
"""Template for printing StockItem labels."""
|
||||||
Template for printing StockItem labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -255,10 +233,7 @@ class StockItemLabel(LabelTemplate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""Generate context data for each provided StockItem."""
|
||||||
Generate context data for each provided StockItem
|
|
||||||
"""
|
|
||||||
|
|
||||||
stock_item = self.object_to_print
|
stock_item = self.object_to_print
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -279,9 +254,7 @@ class StockItemLabel(LabelTemplate):
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabel(LabelTemplate):
|
class StockLocationLabel(LabelTemplate):
|
||||||
"""
|
"""Template for printing StockLocation labels."""
|
||||||
Template for printing StockLocation labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -298,10 +271,7 @@ class StockLocationLabel(LabelTemplate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""Generate context data for each provided StockLocation."""
|
||||||
Generate context data for each provided StockLocation
|
|
||||||
"""
|
|
||||||
|
|
||||||
location = self.object_to_print
|
location = self.object_to_print
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -311,9 +281,7 @@ class StockLocationLabel(LabelTemplate):
|
|||||||
|
|
||||||
|
|
||||||
class PartLabel(LabelTemplate):
|
class PartLabel(LabelTemplate):
|
||||||
"""
|
"""Template for printing Part labels."""
|
||||||
Template for printing Part labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -331,10 +299,7 @@ class PartLabel(LabelTemplate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""Generate context data for each provided Part object."""
|
||||||
Generate context data for each provided Part object
|
|
||||||
"""
|
|
||||||
|
|
||||||
part = self.object_to_print
|
part = self.object_to_print
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -5,9 +5,7 @@ from .models import PartLabel, StockItemLabel, StockLocationLabel
|
|||||||
|
|
||||||
|
|
||||||
class StockItemLabelSerializer(InvenTreeModelSerializer):
|
class StockItemLabelSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializes a StockItemLabel object."""
|
||||||
Serializes a StockItemLabel object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
label = InvenTreeAttachmentSerializerField(required=True)
|
label = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
@ -24,9 +22,7 @@ class StockItemLabelSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializes a StockLocationLabel object."""
|
||||||
Serializes a StockLocationLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
label = InvenTreeAttachmentSerializerField(required=True)
|
label = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
@ -43,9 +39,7 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class PartLabelSerializer(InvenTreeModelSerializer):
|
class PartLabelSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""Serializes a PartLabel object."""
|
||||||
Serializes a PartLabel object
|
|
||||||
"""
|
|
||||||
|
|
||||||
label = InvenTreeAttachmentSerializerField(required=True)
|
label = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
|
@ -6,9 +6,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
|||||||
|
|
||||||
|
|
||||||
class TestReportTests(InvenTreeAPITestCase):
|
class TestReportTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""Tests for the StockItem TestReport templates."""
|
||||||
Tests for the StockItem TestReport templates
|
|
||||||
"""
|
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Tests for labels
|
"""Tests for labels"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -30,10 +30,7 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
apps.get_app_config('label').create_labels()
|
apps.get_app_config('label').create_labels()
|
||||||
|
|
||||||
def test_default_labels(self):
|
def test_default_labels(self):
|
||||||
"""
|
"""Test that the default label templates are copied across."""
|
||||||
Test that the default label templates are copied across
|
|
||||||
"""
|
|
||||||
|
|
||||||
labels = StockItemLabel.objects.all()
|
labels = StockItemLabel.objects.all()
|
||||||
|
|
||||||
self.assertTrue(labels.count() > 0)
|
self.assertTrue(labels.count() > 0)
|
||||||
@ -43,10 +40,7 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
self.assertTrue(labels.count() > 0)
|
self.assertTrue(labels.count() > 0)
|
||||||
|
|
||||||
def test_default_files(self):
|
def test_default_files(self):
|
||||||
"""
|
"""Test that label files exist in the MEDIA directory."""
|
||||||
Test that label files exist in the MEDIA directory
|
|
||||||
"""
|
|
||||||
|
|
||||||
item_dir = os.path.join(
|
item_dir = os.path.join(
|
||||||
settings.MEDIA_ROOT,
|
settings.MEDIA_ROOT,
|
||||||
'label',
|
'label',
|
||||||
@ -70,10 +64,7 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
self.assertTrue(len(files) > 0)
|
self.assertTrue(len(files) > 0)
|
||||||
|
|
||||||
def test_filters(self):
|
def test_filters(self):
|
||||||
"""
|
"""Test the label filters."""
|
||||||
Test the label filters
|
|
||||||
"""
|
|
||||||
|
|
||||||
filter_string = "part__pk=10"
|
filter_string = "part__pk=10"
|
||||||
|
|
||||||
filters = validateFilterString(filter_string, model=StockItem)
|
filters = validateFilterString(filter_string, model=StockItem)
|
||||||
@ -86,8 +77,7 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
validateFilterString(bad_filter_string, model=StockItem)
|
validateFilterString(bad_filter_string, model=StockItem)
|
||||||
|
|
||||||
def test_label_rendering(self):
|
def test_label_rendering(self):
|
||||||
"""Test label rendering"""
|
"""Test label rendering."""
|
||||||
|
|
||||||
labels = PartLabel.objects.all()
|
labels = PartLabel.objects.all()
|
||||||
part = Part.objects.first()
|
part = Part.objects.first()
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
"""The Order module is responsible for managing Orders"""
|
"""The Order module is responsible for managing Orders."""
|
||||||
|
@ -91,9 +91,7 @@ class SalesOrderAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderResource(ModelResource):
|
class PurchaseOrderResource(ModelResource):
|
||||||
"""
|
"""Class for managing import / export of PurchaseOrder data."""
|
||||||
Class for managing import / export of PurchaseOrder data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Add number of line items
|
# Add number of line items
|
||||||
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
|
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
|
||||||
@ -111,7 +109,7 @@ class PurchaseOrderResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemResource(ModelResource):
|
class PurchaseOrderLineItemResource(ModelResource):
|
||||||
""" Class for managing import / export of PurchaseOrderLineItem data """
|
"""Class for managing import / export of PurchaseOrderLineItem data."""
|
||||||
|
|
||||||
part_name = Field(attribute='part__part__name', readonly=True)
|
part_name = Field(attribute='part__part__name', readonly=True)
|
||||||
|
|
||||||
@ -129,16 +127,14 @@ class PurchaseOrderLineItemResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineResource(ModelResource):
|
class PurchaseOrderExtraLineResource(ModelResource):
|
||||||
""" Class for managing import / export of PurchaseOrderExtraLine data """
|
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
||||||
|
|
||||||
class Meta(GeneralExtraLineMeta):
|
class Meta(GeneralExtraLineMeta):
|
||||||
model = PurchaseOrderExtraLine
|
model = PurchaseOrderExtraLine
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderResource(ModelResource):
|
class SalesOrderResource(ModelResource):
|
||||||
"""
|
"""Class for managing import / export of SalesOrder data."""
|
||||||
Class for managing import / export of SalesOrder data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Add number of line items
|
# Add number of line items
|
||||||
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
|
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
|
||||||
@ -156,9 +152,7 @@ class SalesOrderResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemResource(ModelResource):
|
class SalesOrderLineItemResource(ModelResource):
|
||||||
"""
|
"""Class for managing import / export of SalesOrderLineItem data."""
|
||||||
Class for managing import / export of SalesOrderLineItem data
|
|
||||||
"""
|
|
||||||
|
|
||||||
part_name = Field(attribute='part__name', readonly=True)
|
part_name = Field(attribute='part__name', readonly=True)
|
||||||
|
|
||||||
@ -169,11 +163,10 @@ class SalesOrderLineItemResource(ModelResource):
|
|||||||
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
|
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
|
||||||
|
|
||||||
def dehydrate_sale_price(self, item):
|
def dehydrate_sale_price(self, item):
|
||||||
"""
|
"""Return a string value of the 'sale_price' field, rather than the 'Money' object.
|
||||||
Return a string value of the 'sale_price' field, rather than the 'Money' object.
|
|
||||||
Ref: https://github.com/inventree/InvenTree/issues/2207
|
Ref: https://github.com/inventree/InvenTree/issues/2207
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if item.sale_price:
|
if item.sale_price:
|
||||||
return str(item.sale_price)
|
return str(item.sale_price)
|
||||||
else:
|
else:
|
||||||
@ -187,7 +180,7 @@ class SalesOrderLineItemResource(ModelResource):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineResource(ModelResource):
|
class SalesOrderExtraLineResource(ModelResource):
|
||||||
""" Class for managing import / export of SalesOrderExtraLine data """
|
"""Class for managing import / export of SalesOrderExtraLine data."""
|
||||||
|
|
||||||
class Meta(GeneralExtraLineMeta):
|
class Meta(GeneralExtraLineMeta):
|
||||||
model = SalesOrderExtraLine
|
model = SalesOrderExtraLine
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""JSON API for the Order app."""
|
||||||
JSON API for the Order app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
@ -24,9 +22,7 @@ from users.models import Owner
|
|||||||
|
|
||||||
|
|
||||||
class GeneralExtraLineList:
|
class GeneralExtraLineList:
|
||||||
"""
|
"""General template for ExtraLine API classes."""
|
||||||
General template for ExtraLine API classes
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@ -76,17 +72,12 @@ class GeneralExtraLineList:
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderFilter(rest_filters.FilterSet):
|
class PurchaseOrderFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom API filters for the PurchaseOrderList endpoint."""
|
||||||
Custom API filters for the PurchaseOrderList endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
||||||
|
|
||||||
def filter_assigned_to_me(self, queryset, name, value):
|
def filter_assigned_to_me(self, queryset, name, value):
|
||||||
"""
|
"""Filter by orders which are assigned to the current user."""
|
||||||
Filter by orders which are assigned to the current user
|
|
||||||
"""
|
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
# Work out who "me" is!
|
# Work out who "me" is!
|
||||||
@ -107,7 +98,7 @@ class PurchaseOrderFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of PurchaseOrder objects
|
"""API endpoint for accessing a list of PurchaseOrder objects.
|
||||||
|
|
||||||
- GET: Return list of PurchaseOrder objects (with filters)
|
- GET: Return list of PurchaseOrder objects (with filters)
|
||||||
- POST: Create a new PurchaseOrder object
|
- POST: Create a new PurchaseOrder object
|
||||||
@ -118,9 +109,7 @@ class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
filterset_class = PurchaseOrderFilter
|
filterset_class = PurchaseOrderFilter
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""Save user information on create."""
|
||||||
Save user information on create
|
|
||||||
"""
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@ -260,7 +249,7 @@ class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a PurchaseOrder object """
|
"""API endpoint for detail view of a PurchaseOrder object."""
|
||||||
|
|
||||||
queryset = models.PurchaseOrder.objects.all()
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderSerializer
|
serializer_class = serializers.PurchaseOrderSerializer
|
||||||
@ -292,11 +281,10 @@ class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderContextMixin:
|
class PurchaseOrderContextMixin:
|
||||||
""" Mixin to add purchase order object as serializer context variable """
|
"""Mixin to add purchase order object as serializer context variable."""
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
""" Add the PurchaseOrder object to the serializer context """
|
"""Add the PurchaseOrder object to the serializer context."""
|
||||||
|
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
|
|
||||||
# Pass the purchase order through to the serializer for validation
|
# Pass the purchase order through to the serializer for validation
|
||||||
@ -311,8 +299,7 @@ class PurchaseOrderContextMixin:
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
|
class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to 'cancel' a purchase order.
|
||||||
API endpoint to 'cancel' a purchase order.
|
|
||||||
|
|
||||||
The purchase order must be in a state which can be cancelled
|
The purchase order must be in a state which can be cancelled
|
||||||
"""
|
"""
|
||||||
@ -323,9 +310,7 @@ class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
|
class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to 'complete' a purchase order."""
|
||||||
API endpoint to 'complete' a purchase order
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrder.objects.all()
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
|
|
||||||
@ -333,9 +318,7 @@ class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to 'complete' a purchase order."""
|
||||||
API endpoint to 'complete' a purchase order
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrder.objects.all()
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
|
|
||||||
@ -343,7 +326,7 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
"""API endpoint for viewing / updating PurchaseOrder metadata"""
|
"""API endpoint for viewing / updating PurchaseOrder metadata."""
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
||||||
@ -352,8 +335,7 @@ class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
- The purchase order is specified in the URL.
|
- The purchase order is specified in the URL.
|
||||||
- Items to receive are specified as a list called "items" with the following options:
|
- Items to receive are specified as a list called "items" with the following options:
|
||||||
@ -370,9 +352,7 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom filters for the PurchaseOrderLineItemList endpoint."""
|
||||||
Custom filters for the PurchaseOrderLineItemList endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PurchaseOrderLineItem
|
model = models.PurchaseOrderLineItem
|
||||||
@ -384,10 +364,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
|||||||
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
|
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
|
||||||
|
|
||||||
def filter_pending(self, queryset, name, value):
|
def filter_pending(self, queryset, name, value):
|
||||||
"""
|
"""Filter by "pending" status (order status = pending)"""
|
||||||
Filter by "pending" status (order status = pending)
|
|
||||||
"""
|
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
@ -402,12 +379,10 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
|||||||
received = rest_filters.BooleanFilter(label='received', method='filter_received')
|
received = rest_filters.BooleanFilter(label='received', method='filter_received')
|
||||||
|
|
||||||
def filter_received(self, queryset, name, value):
|
def filter_received(self, queryset, name, value):
|
||||||
"""
|
"""Filter by lines which are "received" (or "not" received)
|
||||||
Filter by lines which are "received" (or "not" received)
|
|
||||||
|
|
||||||
A line is considered "received" when received >= quantity
|
A line is considered "received" when received >= quantity
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
q = Q(received__gte=F('quantity'))
|
q = Q(received__gte=F('quantity'))
|
||||||
@ -422,7 +397,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
|
class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of PurchaseOrderLineItem objects
|
"""API endpoint for accessing a list of PurchaseOrderLineItem objects.
|
||||||
|
|
||||||
- GET: Return a list of PurchaseOrder Line Item objects
|
- GET: Return a list of PurchaseOrder Line Item objects
|
||||||
- POST: Create a new PurchaseOrderLineItem object
|
- POST: Create a new PurchaseOrderLineItem object
|
||||||
@ -453,10 +428,7 @@ class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Additional filtering options."""
|
||||||
Additional filtering options
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
@ -530,9 +502,7 @@ class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PurchaseOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""Detail API endpoint for PurchaseOrderLineItem object."""
|
||||||
Detail API endpoint for PurchaseOrderLineItem object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderLineItemSerializer
|
serializer_class = serializers.PurchaseOrderLineItemSerializer
|
||||||
@ -547,25 +517,21 @@ class PurchaseOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView):
|
class PurchaseOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
|
||||||
API endpoint for accessing a list of PurchaseOrderExtraLine objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrderExtraLine.objects.all()
|
queryset = models.PurchaseOrderExtraLine.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a PurchaseOrderExtraLine object """
|
"""API endpoint for detail view of a PurchaseOrderExtraLine object."""
|
||||||
|
|
||||||
queryset = models.PurchaseOrderExtraLine.objects.all()
|
queryset = models.PurchaseOrderExtraLine.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
||||||
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderAttachmentSerializer
|
serializer_class = serializers.SalesOrderAttachmentSerializer
|
||||||
@ -580,17 +546,14 @@ class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
||||||
"""
|
"""Detail endpoint for SalesOrderAttachment."""
|
||||||
Detail endpoint for SalesOrderAttachment
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderAttachmentSerializer
|
serializer_class = serializers.SalesOrderAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for accessing a list of SalesOrder objects.
|
||||||
API endpoint for accessing a list of SalesOrder objects.
|
|
||||||
|
|
||||||
- GET: Return list of SalesOrder objects (with filters)
|
- GET: Return list of SalesOrder objects (with filters)
|
||||||
- POST: Create a new SalesOrder
|
- POST: Create a new SalesOrder
|
||||||
@ -600,9 +563,7 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
serializer_class = serializers.SalesOrderSerializer
|
serializer_class = serializers.SalesOrderSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""Save user information on create."""
|
||||||
Save user information on create
|
|
||||||
"""
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@ -648,10 +609,7 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
return DownloadFile(filedata, filename)
|
return DownloadFile(filedata, filename)
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""Perform custom filtering operations on the SalesOrder queryset."""
|
||||||
Perform custom filtering operations on the SalesOrder queryset.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
@ -739,9 +697,7 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SalesOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for detail view of a SalesOrder object."""
|
||||||
API endpoint for detail view of a SalesOrder object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrder.objects.all()
|
queryset = models.SalesOrder.objects.all()
|
||||||
serializer_class = serializers.SalesOrderSerializer
|
serializer_class = serializers.SalesOrderSerializer
|
||||||
@ -769,9 +725,7 @@ class SalesOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom filters for SalesOrderLineItemList endpoint."""
|
||||||
Custom filters for SalesOrderLineItemList endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.SalesOrderLineItem
|
model = models.SalesOrderLineItem
|
||||||
@ -783,12 +737,10 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
|||||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
||||||
|
|
||||||
def filter_completed(self, queryset, name, value):
|
def filter_completed(self, queryset, name, value):
|
||||||
"""
|
"""Filter by lines which are "completed".
|
||||||
Filter by lines which are "completed"
|
|
||||||
|
|
||||||
A line is completed when shipped >= quantity
|
A line is completed when shipped >= quantity
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str2bool(value)
|
value = str2bool(value)
|
||||||
|
|
||||||
q = Q(shipped__gte=F('quantity'))
|
q = Q(shipped__gte=F('quantity'))
|
||||||
@ -802,9 +754,7 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemList(generics.ListCreateAPIView):
|
class SalesOrderLineItemList(generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for accessing a list of SalesOrderLineItem objects."""
|
||||||
API endpoint for accessing a list of SalesOrderLineItem objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderLineItem.objects.all()
|
queryset = models.SalesOrderLineItem.objects.all()
|
||||||
serializer_class = serializers.SalesOrderLineItemSerializer
|
serializer_class = serializers.SalesOrderLineItemSerializer
|
||||||
@ -866,30 +816,28 @@ class SalesOrderLineItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView):
|
class SalesOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView):
|
||||||
"""
|
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
|
||||||
API endpoint for accessing a list of SalesOrderExtraLine objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderExtraLine.objects.all()
|
queryset = models.SalesOrderExtraLine.objects.all()
|
||||||
serializer_class = serializers.SalesOrderExtraLineSerializer
|
serializer_class = serializers.SalesOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SalesOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a SalesOrderExtraLine object """
|
"""API endpoint for detail view of a SalesOrderExtraLine object."""
|
||||||
|
|
||||||
queryset = models.SalesOrderExtraLine.objects.all()
|
queryset = models.SalesOrderExtraLine.objects.all()
|
||||||
serializer_class = serializers.SalesOrderExtraLineSerializer
|
serializer_class = serializers.SalesOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a SalesOrderLineItem object """
|
"""API endpoint for detail view of a SalesOrderLineItem object."""
|
||||||
|
|
||||||
queryset = models.SalesOrderLineItem.objects.all()
|
queryset = models.SalesOrderLineItem.objects.all()
|
||||||
serializer_class = serializers.SalesOrderLineItemSerializer
|
serializer_class = serializers.SalesOrderLineItemSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderContextMixin:
|
class SalesOrderContextMixin:
|
||||||
""" Mixin to add sales order object as serializer context variable """
|
"""Mixin to add sales order object as serializer context variable."""
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
|
||||||
@ -912,16 +860,14 @@ class SalesOrderCancel(SalesOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for manually marking a SalesOrder as "complete"."""
|
||||||
API endpoint for manually marking a SalesOrder as "complete".
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrder.objects.all()
|
queryset = models.SalesOrder.objects.all()
|
||||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
"""API endpoint for viewing / updating SalesOrder metadata"""
|
"""API endpoint for viewing / updating SalesOrder metadata."""
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
||||||
@ -930,18 +876,14 @@ class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to allocation stock items against a SalesOrder, by specifying serial numbers."""
|
||||||
API endpoint to allocation stock items against a SalesOrder,
|
|
||||||
by specifying serial numbers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrder.objects.none()
|
queryset = models.SalesOrder.objects.none()
|
||||||
serializer_class = serializers.SalesOrderSerialAllocationSerializer
|
serializer_class = serializers.SalesOrderSerialAllocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
|
class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint to allocate stock items against a SalesOrder.
|
||||||
API endpoint to allocate stock items against a SalesOrder
|
|
||||||
|
|
||||||
- The SalesOrder is specified in the URL
|
- The SalesOrder is specified in the URL
|
||||||
- See the SalesOrderShipmentAllocationSerializer class
|
- See the SalesOrderShipmentAllocationSerializer class
|
||||||
@ -952,18 +894,14 @@ class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API endpoint for detali view of a SalesOrderAllocation object."""
|
||||||
API endpoint for detali view of a SalesOrderAllocation object
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderAllocation.objects.all()
|
queryset = models.SalesOrderAllocation.objects.all()
|
||||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocationList(generics.ListAPIView):
|
class SalesOrderAllocationList(generics.ListAPIView):
|
||||||
"""
|
"""API endpoint for listing SalesOrderAllocation objects."""
|
||||||
API endpoint for listing SalesOrderAllocation objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderAllocation.objects.all()
|
queryset = models.SalesOrderAllocation.objects.all()
|
||||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||||
@ -1039,9 +977,7 @@ class SalesOrderAllocationList(generics.ListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
||||||
"""
|
"""Custom filterset for the SalesOrderShipmentList endpoint."""
|
||||||
Custom filterset for the SalesOrderShipmentList endpoint
|
|
||||||
"""
|
|
||||||
|
|
||||||
shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
|
shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
|
||||||
|
|
||||||
@ -1064,9 +1000,7 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentList(generics.ListCreateAPIView):
|
class SalesOrderShipmentList(generics.ListCreateAPIView):
|
||||||
"""
|
"""API list endpoint for SalesOrderShipment model."""
|
||||||
API list endpoint for SalesOrderShipment model
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||||
@ -1078,27 +1012,20 @@ class SalesOrderShipmentList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SalesOrderShipmentDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""API detail endpooint for SalesOrderShipment model."""
|
||||||
API detail endpooint for SalesOrderShipment model
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentComplete(generics.CreateAPIView):
|
class SalesOrderShipmentComplete(generics.CreateAPIView):
|
||||||
"""
|
"""API endpoint for completing (shipping) a SalesOrderShipment."""
|
||||||
API endpoint for completing (shipping) a SalesOrderShipment
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
|
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
"""
|
"""Pass the request object to the serializer."""
|
||||||
Pass the request object to the serializer
|
|
||||||
"""
|
|
||||||
|
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['request'] = self.request
|
ctx['request'] = self.request
|
||||||
|
|
||||||
@ -1113,9 +1040,7 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
||||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||||
@ -1130,9 +1055,7 @@ class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
||||||
"""
|
"""Detail endpoint for a PurchaseOrderAttachment."""
|
||||||
Detail endpoint for a PurchaseOrderAttachment
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Django Forms for interacting with Order objects."""
|
||||||
Django Forms for interacting with Order objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -11,11 +9,10 @@ from InvenTree.helpers import clean_decimal
|
|||||||
|
|
||||||
|
|
||||||
class OrderMatchItemForm(MatchItemForm):
|
class OrderMatchItemForm(MatchItemForm):
|
||||||
""" Override MatchItemForm fields """
|
"""Override MatchItemForm fields."""
|
||||||
|
|
||||||
def get_special_field(self, col_guess, row, file_manager):
|
def get_special_field(self, col_guess, row, file_manager):
|
||||||
""" Set special fields """
|
"""Set special fields."""
|
||||||
|
|
||||||
# set quantity field
|
# set quantity field
|
||||||
if 'quantity' in col_guess.lower():
|
if 'quantity' in col_guess.lower():
|
||||||
return forms.CharField(
|
return forms.CharField(
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Order model definitions"""
|
"""Order model definitions."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -43,7 +43,7 @@ logger = logging.getLogger('inventree')
|
|||||||
|
|
||||||
|
|
||||||
def get_next_po_number():
|
def get_next_po_number():
|
||||||
"""Returns the next available PurchaseOrder reference number"""
|
"""Returns the next available PurchaseOrder reference number."""
|
||||||
if PurchaseOrder.objects.count() == 0:
|
if PurchaseOrder.objects.count() == 0:
|
||||||
return '0001'
|
return '0001'
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ def get_next_po_number():
|
|||||||
|
|
||||||
|
|
||||||
def get_next_so_number():
|
def get_next_so_number():
|
||||||
"""Returns the next available SalesOrder reference number"""
|
"""Returns the next available SalesOrder reference number."""
|
||||||
if SalesOrder.objects.count() == 0:
|
if SalesOrder.objects.count() == 0:
|
||||||
return '0001'
|
return '0001'
|
||||||
|
|
||||||
@ -235,7 +235,7 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""Filter by 'minimum and maximum date range'
|
"""Filter by 'minimum and maximum date range'.
|
||||||
|
|
||||||
- Specified as min_date, max_date
|
- Specified as min_date, max_date
|
||||||
- Both must be specified for filter to be applied
|
- Both must be specified for filter to be applied
|
||||||
@ -330,8 +330,7 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def add_line_item(self, supplier_part, quantity, group=True, reference='', purchase_price=None):
|
def add_line_item(self, supplier_part, quantity, group=True, reference='', purchase_price=None):
|
||||||
"""Add a new line item to this purchase order.
|
"""Add a new line item to this purchase order. This function will check that:
|
||||||
This function will check that:
|
|
||||||
|
|
||||||
* The supplier part matches the supplier specified for this purchase order
|
* The supplier part matches the supplier specified for this purchase order
|
||||||
* The quantity is greater than zero
|
* The quantity is greater than zero
|
||||||
@ -381,7 +380,10 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def place_order(self):
|
def place_order(self):
|
||||||
"""Marks the PurchaseOrder as PLACED. Order must be currently PENDING."""
|
"""Marks the PurchaseOrder as PLACED.
|
||||||
|
|
||||||
|
Order must be currently PENDING.
|
||||||
|
"""
|
||||||
if self.status == PurchaseOrderStatus.PENDING:
|
if self.status == PurchaseOrderStatus.PENDING:
|
||||||
self.status = PurchaseOrderStatus.PLACED
|
self.status = PurchaseOrderStatus.PLACED
|
||||||
self.issue_date = datetime.now().date()
|
self.issue_date = datetime.now().date()
|
||||||
@ -391,7 +393,10 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def complete_order(self):
|
def complete_order(self):
|
||||||
"""Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED."""
|
"""Marks the PurchaseOrder as COMPLETE.
|
||||||
|
|
||||||
|
Order must be currently PLACED.
|
||||||
|
"""
|
||||||
if self.status == PurchaseOrderStatus.PLACED:
|
if self.status == PurchaseOrderStatus.PLACED:
|
||||||
self.status = PurchaseOrderStatus.COMPLETE
|
self.status = PurchaseOrderStatus.COMPLETE
|
||||||
self.complete_date = datetime.now().date()
|
self.complete_date = datetime.now().date()
|
||||||
@ -401,7 +406,7 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_overdue(self):
|
def is_overdue(self):
|
||||||
"""Returns True if this PurchaseOrder is "overdue"
|
"""Returns True if this PurchaseOrder is "overdue".
|
||||||
|
|
||||||
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
||||||
"""
|
"""
|
||||||
@ -434,7 +439,7 @@ class PurchaseOrder(Order):
|
|||||||
return self.lines.filter(quantity__gt=F('received'))
|
return self.lines.filter(quantity__gt=F('received'))
|
||||||
|
|
||||||
def completed_line_items(self):
|
def completed_line_items(self):
|
||||||
"""Return a list of completed line items against this order"""
|
"""Return a list of completed line items against this order."""
|
||||||
return self.lines.filter(quantity__lte=F('received'))
|
return self.lines.filter(quantity__lte=F('received'))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -452,12 +457,12 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self):
|
def is_complete(self):
|
||||||
"""Return True if all line items have been received"""
|
"""Return True if all line items have been received."""
|
||||||
return self.lines.count() > 0 and self.pending_line_items().count() == 0
|
return self.lines.count() > 0 and self.pending_line_items().count() == 0
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
|
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
|
||||||
"""Receive a line item (or partial line item) against this PurchaseOrder"""
|
"""Receive a line item (or partial line item) against this PurchaseOrder."""
|
||||||
# Extract optional batch code for the new stock item
|
# Extract optional batch code for the new stock item
|
||||||
batch_code = kwargs.get('batch_code', '')
|
batch_code = kwargs.get('batch_code', '')
|
||||||
|
|
||||||
@ -560,7 +565,7 @@ class SalesOrder(Order):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""Filter by "minimum and maximum date range"
|
"""Filter by "minimum and maximum date range".
|
||||||
|
|
||||||
- Specified as min_date, max_date
|
- Specified as min_date, max_date
|
||||||
- Both must be specified for filter to be applied
|
- Both must be specified for filter to be applied
|
||||||
@ -665,13 +670,13 @@ class SalesOrder(Order):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def stock_allocations(self):
|
def stock_allocations(self):
|
||||||
"""Return a queryset containing all allocations for this order"""
|
"""Return a queryset containing all allocations for this order."""
|
||||||
return SalesOrderAllocation.objects.filter(
|
return SalesOrderAllocation.objects.filter(
|
||||||
line__in=[line.pk for line in self.lines.all()]
|
line__in=[line.pk for line in self.lines.all()]
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_fully_allocated(self):
|
def is_fully_allocated(self):
|
||||||
"""Return True if all line items are fully allocated"""
|
"""Return True if all line items are fully allocated."""
|
||||||
for line in self.lines.all():
|
for line in self.lines.all():
|
||||||
if not line.is_fully_allocated():
|
if not line.is_fully_allocated():
|
||||||
return False
|
return False
|
||||||
@ -679,7 +684,7 @@ class SalesOrder(Order):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def is_over_allocated(self):
|
def is_over_allocated(self):
|
||||||
"""Return true if any lines in the order are over-allocated"""
|
"""Return true if any lines in the order are over-allocated."""
|
||||||
for line in self.lines.all():
|
for line in self.lines.all():
|
||||||
if line.is_over_allocated():
|
if line.is_over_allocated():
|
||||||
return True
|
return True
|
||||||
@ -721,7 +726,7 @@ class SalesOrder(Order):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def complete_order(self, user):
|
def complete_order(self, user):
|
||||||
"""Mark this order as "complete"""
|
"""Mark this order as "complete."""
|
||||||
if not self.can_complete():
|
if not self.can_complete():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -736,7 +741,7 @@ class SalesOrder(Order):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_cancel(self):
|
def can_cancel(self):
|
||||||
"""Return True if this order can be cancelled"""
|
"""Return True if this order can be cancelled."""
|
||||||
if self.status != SalesOrderStatus.PENDING:
|
if self.status != SalesOrderStatus.PENDING:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -768,11 +773,11 @@ class SalesOrder(Order):
|
|||||||
return self.lines.count()
|
return self.lines.count()
|
||||||
|
|
||||||
def completed_line_items(self):
|
def completed_line_items(self):
|
||||||
"""Return a queryset of the completed line items for this order"""
|
"""Return a queryset of the completed line items for this order."""
|
||||||
return self.lines.filter(shipped__gte=F('quantity'))
|
return self.lines.filter(shipped__gte=F('quantity'))
|
||||||
|
|
||||||
def pending_line_items(self):
|
def pending_line_items(self):
|
||||||
"""Return a queryset of the pending line items for this order"""
|
"""Return a queryset of the pending line items for this order."""
|
||||||
return self.lines.filter(shipped__lt=F('quantity'))
|
return self.lines.filter(shipped__lt=F('quantity'))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -784,11 +789,11 @@ class SalesOrder(Order):
|
|||||||
return self.pending_line_items().count()
|
return self.pending_line_items().count()
|
||||||
|
|
||||||
def completed_shipments(self):
|
def completed_shipments(self):
|
||||||
"""Return a queryset of the completed shipments for this order"""
|
"""Return a queryset of the completed shipments for this order."""
|
||||||
return self.shipments.exclude(shipment_date=None)
|
return self.shipments.exclude(shipment_date=None)
|
||||||
|
|
||||||
def pending_shipments(self):
|
def pending_shipments(self):
|
||||||
"""Return a queryset of the pending shipments for this order"""
|
"""Return a queryset of the pending shipments for this order."""
|
||||||
return self.shipments.filter(shipment_date=None)
|
return self.shipments.filter(shipment_date=None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -806,7 +811,7 @@ class SalesOrder(Order):
|
|||||||
|
|
||||||
@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
|
@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
|
||||||
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
|
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
|
||||||
"""Callback function to be executed after a SalesOrder instance is saved"""
|
"""Callback function to be executed after a SalesOrder instance is saved."""
|
||||||
if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
|
if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
|
||||||
# A new SalesOrder has just been created
|
# A new SalesOrder has just been created
|
||||||
|
|
||||||
@ -818,7 +823,7 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachment(InvenTreeAttachment):
|
class PurchaseOrderAttachment(InvenTreeAttachment):
|
||||||
"""Model for storing file attachments against a PurchaseOrder object"""
|
"""Model for storing file attachments against a PurchaseOrder object."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -831,7 +836,7 @@ class PurchaseOrderAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachment(InvenTreeAttachment):
|
class SalesOrderAttachment(InvenTreeAttachment):
|
||||||
"""Model for storing file attachments against a SalesOrder object"""
|
"""Model for storing file attachments against a SalesOrder object."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@ -844,7 +849,7 @@ class SalesOrderAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
|
|
||||||
class OrderLineItem(models.Model):
|
class OrderLineItem(models.Model):
|
||||||
"""Abstract model for an order line item
|
"""Abstract model for an order line item.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
quantity: Number of items
|
quantity: Number of items
|
||||||
@ -884,7 +889,7 @@ class OrderLineItem(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class OrderExtraLine(OrderLineItem):
|
class OrderExtraLine(OrderLineItem):
|
||||||
"""Abstract Model for a single ExtraLine in a Order
|
"""Abstract Model for a single ExtraLine in a Order.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
price: The unit sale price for this OrderLineItem
|
price: The unit sale price for this OrderLineItem
|
||||||
@ -957,7 +962,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_base_part(self):
|
def get_base_part(self):
|
||||||
"""Return the base part.Part object for the line item
|
"""Return the base part.Part object for the line item.
|
||||||
|
|
||||||
Note: Returns None if the SupplierPart is not set!
|
Note: Returns None if the SupplierPart is not set!
|
||||||
"""
|
"""
|
||||||
@ -999,7 +1004,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_destination(self):
|
def get_destination(self):
|
||||||
"""Show where the line item is or should be placed
|
"""Show where the line item is or should be placed.
|
||||||
|
|
||||||
NOTE: If a line item gets split when recieved, only an arbitrary
|
NOTE: If a line item gets split when recieved, only an arbitrary
|
||||||
stock items location will be reported as the location for the
|
stock items location will be reported as the location for the
|
||||||
@ -1014,13 +1019,14 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
return self.part.part.default_location
|
return self.part.part.default_location
|
||||||
|
|
||||||
def remaining(self):
|
def remaining(self):
|
||||||
"""Calculate the number of items remaining to be received"""
|
"""Calculate the number of items remaining to be received."""
|
||||||
r = self.quantity - self.received
|
r = self.quantity - self.received
|
||||||
return max(r, 0)
|
return max(r, 0)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLine(OrderExtraLine):
|
class PurchaseOrderExtraLine(OrderExtraLine):
|
||||||
"""Model for a single ExtraLine in a PurchaseOrder
|
"""Model for a single ExtraLine in a PurchaseOrder.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
order: Link to the PurchaseOrder that this line belongs to
|
order: Link to the PurchaseOrder that this line belongs to
|
||||||
title: title of line
|
title: title of line
|
||||||
@ -1034,7 +1040,7 @@ class PurchaseOrderExtraLine(OrderExtraLine):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItem(OrderLineItem):
|
class SalesOrderLineItem(OrderLineItem):
|
||||||
"""Model for a single LineItem in a SalesOrder
|
"""Model for a single LineItem in a SalesOrder.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
order: Link to the SalesOrder that this line item belongs to
|
order: Link to the SalesOrder that this line item belongs to
|
||||||
@ -1093,14 +1099,14 @@ class SalesOrderLineItem(OrderLineItem):
|
|||||||
return query['allocated']
|
return query['allocated']
|
||||||
|
|
||||||
def is_fully_allocated(self):
|
def is_fully_allocated(self):
|
||||||
"""Return True if this line item is fully allocated"""
|
"""Return True if this line item is fully allocated."""
|
||||||
if self.order.status == SalesOrderStatus.SHIPPED:
|
if self.order.status == SalesOrderStatus.SHIPPED:
|
||||||
return self.fulfilled_quantity() >= self.quantity
|
return self.fulfilled_quantity() >= self.quantity
|
||||||
|
|
||||||
return self.allocated_quantity() >= self.quantity
|
return self.allocated_quantity() >= self.quantity
|
||||||
|
|
||||||
def is_over_allocated(self):
|
def is_over_allocated(self):
|
||||||
"""Return True if this line item is over allocated"""
|
"""Return True if this line item is over allocated."""
|
||||||
return self.allocated_quantity() > self.quantity
|
return self.allocated_quantity() > self.quantity
|
||||||
|
|
||||||
def is_completed(self):
|
def is_completed(self):
|
||||||
@ -1260,7 +1266,7 @@ class SalesOrderShipment(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLine(OrderExtraLine):
|
class SalesOrderExtraLine(OrderExtraLine):
|
||||||
"""Model for a single ExtraLine in a SalesOrder
|
"""Model for a single ExtraLine in a SalesOrder.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
order: Link to the SalesOrder that this line belongs to
|
order: Link to the SalesOrder that this line belongs to
|
||||||
@ -1275,9 +1281,7 @@ class SalesOrderExtraLine(OrderExtraLine):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocation(models.Model):
|
class SalesOrderAllocation(models.Model):
|
||||||
"""This model is used to 'allocate' stock items to a SalesOrder.
|
"""This model is used to 'allocate' stock items to a SalesOrder. Items that are "allocated" to a SalesOrder are not yet "attached" to the order, but they will be once the order is fulfilled.
|
||||||
Items that are "allocated" to a SalesOrder are not yet "attached" to the order,
|
|
||||||
but they will be once the order is fulfilled.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
line: SalesOrderLineItem reference
|
line: SalesOrderLineItem reference
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""JSON serializers for the Order API"""
|
"""JSON serializers for the Order API."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -31,7 +31,7 @@ from users.serializers import OwnerSerializer
|
|||||||
|
|
||||||
|
|
||||||
class AbstractOrderSerializer(serializers.Serializer):
|
class AbstractOrderSerializer(serializers.Serializer):
|
||||||
"""Abstract field definitions for OrderSerializers"""
|
"""Abstract field definitions for OrderSerializers."""
|
||||||
|
|
||||||
total_price = InvenTreeMoneySerializer(
|
total_price = InvenTreeMoneySerializer(
|
||||||
source='get_total_price',
|
source='get_total_price',
|
||||||
@ -43,7 +43,7 @@ class AbstractOrderSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class AbstractExtraLineSerializer(serializers.Serializer):
|
class AbstractExtraLineSerializer(serializers.Serializer):
|
||||||
"""Abstract Serializer for a ExtraLine object"""
|
"""Abstract Serializer for a ExtraLine object."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ class AbstractExtraLineSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class AbstractExtraLineMeta:
|
class AbstractExtraLineMeta:
|
||||||
"""Abstract Meta for ExtraLine"""
|
"""Abstract Meta for ExtraLine."""
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -86,7 +86,7 @@ class AbstractExtraLineMeta:
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||||
"""Serializer for a PurchaseOrder object"""
|
"""Serializer for a PurchaseOrder object."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
"""Add extra information to the queryset
|
"""Add extra information to the queryset.
|
||||||
|
|
||||||
- Number of lines in the PurchaseOrder
|
- Number of lines in the PurchaseOrder
|
||||||
- Overdue status of the PurchaseOrder
|
- Overdue status of the PurchaseOrder
|
||||||
@ -166,13 +166,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderCancelSerializer(serializers.Serializer):
|
class PurchaseOrderCancelSerializer(serializers.Serializer):
|
||||||
"""Serializer for cancelling a PurchaseOrder"""
|
"""Serializer for cancelling a PurchaseOrder."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [],
|
fields = [],
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
"""Return custom context information about the order"""
|
"""Return custom context information about the order."""
|
||||||
self.order = self.context['order']
|
self.order = self.context['order']
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -190,13 +190,13 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderCompleteSerializer(serializers.Serializer):
|
class PurchaseOrderCompleteSerializer(serializers.Serializer):
|
||||||
"""Serializer for completing a purchase order"""
|
"""Serializer for completing a purchase order."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = []
|
fields = []
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
"""Custom context information for this serializer"""
|
"""Custom context information for this serializer."""
|
||||||
order = self.context['order']
|
order = self.context['order']
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -210,7 +210,7 @@ class PurchaseOrderCompleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderIssueSerializer(serializers.Serializer):
|
class PurchaseOrderIssueSerializer(serializers.Serializer):
|
||||||
"""Serializer for issuing (sending) a purchase order"""
|
"""Serializer for issuing (sending) a purchase order."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = []
|
fields = []
|
||||||
@ -356,7 +356,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||||
"""Serializer for a PurchaseOrderExtraLine object"""
|
"""Serializer for a PurchaseOrderExtraLine object."""
|
||||||
|
|
||||||
order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True)
|
order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True)
|
||||||
|
|
||||||
@ -365,7 +365,7 @@ class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeMod
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||||
"""A serializer for receiving a single purchase order line item against a purchase order"""
|
"""A serializer for receiving a single purchase order line item against a purchase order."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -448,7 +448,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_barcode(self, barcode):
|
def validate_barcode(self, barcode):
|
||||||
"""Cannot check in a LineItem with a barcode that is already assigned"""
|
"""Cannot check in a LineItem with a barcode that is already assigned."""
|
||||||
# Ignore empty barcode values
|
# Ignore empty barcode values
|
||||||
if not barcode or barcode.strip() == '':
|
if not barcode or barcode.strip() == '':
|
||||||
return None
|
return None
|
||||||
@ -490,7 +490,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||||
"""Serializer for receiving items against a purchase order"""
|
"""Serializer for receiving items against a purchase order."""
|
||||||
|
|
||||||
items = PurchaseOrderLineItemReceiveSerializer(many=True)
|
items = PurchaseOrderLineItemReceiveSerializer(many=True)
|
||||||
|
|
||||||
@ -546,8 +546,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Perform the actual database transaction to receive purchase order items"""
|
"""Perform the actual database transaction to receive purchase order items."""
|
||||||
|
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
@ -586,7 +585,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""Serializers for the PurchaseOrderAttachment model"""
|
"""Serializers for the PurchaseOrderAttachment model."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = order.models.PurchaseOrderAttachment
|
model = order.models.PurchaseOrderAttachment
|
||||||
@ -607,7 +606,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||||
"""Serializers for the SalesOrder object"""
|
"""Serializers for the SalesOrder object."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -620,7 +619,7 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
"""Add extra information to the queryset
|
"""Add extra information to the queryset.
|
||||||
|
|
||||||
- Number of line items in the SalesOrder
|
- Number of line items in the SalesOrder
|
||||||
- Overdue status of the SalesOrder
|
- Overdue status of the SalesOrder
|
||||||
@ -750,7 +749,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for a SalesOrderLineItem object"""
|
"""Serializer for a SalesOrderLineItem object."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
@ -831,7 +830,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for the SalesOrderShipment class"""
|
"""Serializer for the SalesOrderShipment class."""
|
||||||
|
|
||||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||||
|
|
||||||
@ -856,7 +855,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for completing (shipping) a SalesOrderShipment"""
|
"""Serializer for completing (shipping) a SalesOrderShipment."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = order.models.SalesOrderShipment
|
model = order.models.SalesOrderShipment
|
||||||
@ -906,7 +905,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
||||||
"""A serializer for allocating a single stock-item against a SalesOrder shipment"""
|
"""A serializer for allocating a single stock-item against a SalesOrder shipment."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -978,7 +977,7 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderCompleteSerializer(serializers.Serializer):
|
class SalesOrderCompleteSerializer(serializers.Serializer):
|
||||||
"""DRF serializer for manually marking a sales order as complete"""
|
"""DRF serializer for manually marking a sales order as complete."""
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
@ -1001,7 +1000,7 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderCancelSerializer(serializers.Serializer):
|
class SalesOrderCancelSerializer(serializers.Serializer):
|
||||||
"""Serializer for marking a SalesOrder as cancelled"""
|
"""Serializer for marking a SalesOrder as cancelled."""
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
|
|
||||||
@ -1019,7 +1018,7 @@ class SalesOrderCancelSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
||||||
"""DRF serializer for allocation of serial numbers against a sales order / shipment"""
|
"""DRF serializer for allocation of serial numbers against a sales order / shipment."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -1038,7 +1037,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_line_item(self, line_item):
|
def validate_line_item(self, line_item):
|
||||||
"""Ensure that the line_item is valid"""
|
"""Ensure that the line_item is valid."""
|
||||||
order = self.context['order']
|
order = self.context['order']
|
||||||
|
|
||||||
# Ensure that the line item points to the correct order
|
# Ensure that the line item points to the correct order
|
||||||
@ -1173,7 +1172,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||||
"""DRF serializer for allocation of stock items against a sales order / shipment"""
|
"""DRF serializer for allocation of stock items against a sales order / shipment."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
@ -1192,7 +1191,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_shipment(self, shipment):
|
def validate_shipment(self, shipment):
|
||||||
"""Run validation against the provided shipment instance"""
|
"""Run validation against the provided shipment instance."""
|
||||||
order = self.context['order']
|
order = self.context['order']
|
||||||
|
|
||||||
if shipment.shipment_date is not None:
|
if shipment.shipment_date is not None:
|
||||||
@ -1204,7 +1203,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
return shipment
|
return shipment
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""Serializer validation"""
|
"""Serializer validation."""
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
|
|
||||||
# Extract SalesOrder from serializer context
|
# Extract SalesOrder from serializer context
|
||||||
@ -1218,7 +1217,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Perform the allocation of items against this order"""
|
"""Perform the allocation of items against this order."""
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
|
|
||||||
items = data['items']
|
items = data['items']
|
||||||
@ -1240,7 +1239,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||||
"""Serializer for a SalesOrderExtraLine object"""
|
"""Serializer for a SalesOrderExtraLine object."""
|
||||||
|
|
||||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||||
|
|
||||||
@ -1249,7 +1248,7 @@ class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelS
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""Serializers for the SalesOrderAttachment model"""
|
"""Serializers for the SalesOrderAttachment model."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = order.models.SalesOrderAttachment
|
model = order.models.SalesOrderAttachment
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Tests for the Order API"""
|
"""Tests for the Order API."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -37,7 +37,7 @@ class OrderTest(InvenTreeAPITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
def filter(self, filters, count):
|
def filter(self, filters, count):
|
||||||
"""Test API filters"""
|
"""Test API filters."""
|
||||||
response = self.get(
|
response = self.get(
|
||||||
self.LIST_URL,
|
self.LIST_URL,
|
||||||
filters
|
filters
|
||||||
@ -50,7 +50,7 @@ class OrderTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderTest(OrderTest):
|
class PurchaseOrderTest(OrderTest):
|
||||||
"""Tests for the PurchaseOrder API"""
|
"""Tests for the PurchaseOrder API."""
|
||||||
|
|
||||||
LIST_URL = reverse('api-po-list')
|
LIST_URL = reverse('api-po-list')
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.filter({'status': 40}, 1)
|
self.filter({'status': 40}, 1)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""Test "overdue" status"""
|
"""Test "overdue" status."""
|
||||||
self.filter({'overdue': True}, 0)
|
self.filter({'overdue': True}, 0)
|
||||||
self.filter({'overdue': False}, 7)
|
self.filter({'overdue': False}, 7)
|
||||||
|
|
||||||
@ -97,7 +97,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.assertEqual(data['description'], 'Ordering some screws')
|
self.assertEqual(data['description'], 'Ordering some screws')
|
||||||
|
|
||||||
def test_po_reference(self):
|
def test_po_reference(self):
|
||||||
"""test that a reference with a too big / small reference is not possible"""
|
"""Test that a reference with a too big / small reference is not possible."""
|
||||||
# get permissions
|
# get permissions
|
||||||
self.assignRole('purchase_order.add')
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
@ -123,7 +123,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_po_operations(self):
|
def test_po_operations(self):
|
||||||
"""Test that we can create / edit and delete a PurchaseOrder via the API"""
|
"""Test that we can create / edit and delete a PurchaseOrder via the API."""
|
||||||
n = models.PurchaseOrder.objects.count()
|
n = models.PurchaseOrder.objects.count()
|
||||||
|
|
||||||
url = reverse('api-po-list')
|
url = reverse('api-po-list')
|
||||||
@ -210,7 +210,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
def test_po_create(self):
|
def test_po_create(self):
|
||||||
"""Test that we can create a new PurchaseOrder via the API"""
|
"""Test that we can create a new PurchaseOrder via the API."""
|
||||||
self.assignRole('purchase_order.add')
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
self.post(
|
self.post(
|
||||||
@ -224,7 +224,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_po_cancel(self):
|
def test_po_cancel(self):
|
||||||
"""Test the PurchaseOrderCancel API endpoint"""
|
"""Test the PurchaseOrderCancel API endpoint."""
|
||||||
po = models.PurchaseOrder.objects.get(pk=1)
|
po = models.PurchaseOrder.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
|
self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
|
||||||
@ -250,7 +250,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.post(url, {}, expected_code=400)
|
self.post(url, {}, expected_code=400)
|
||||||
|
|
||||||
def test_po_complete(self):
|
def test_po_complete(self):
|
||||||
""" Test the PurchaseOrderComplete API endpoint """
|
"""Test the PurchaseOrderComplete API endpoint."""
|
||||||
po = models.PurchaseOrder.objects.get(pk=3)
|
po = models.PurchaseOrder.objects.get(pk=3)
|
||||||
|
|
||||||
url = reverse('api-po-complete', kwargs={'pk': po.pk})
|
url = reverse('api-po-complete', kwargs={'pk': po.pk})
|
||||||
@ -269,7 +269,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
def test_po_issue(self):
|
def test_po_issue(self):
|
||||||
""" Test the PurchaseOrderIssue API endpoint """
|
"""Test the PurchaseOrderIssue API endpoint."""
|
||||||
po = models.PurchaseOrder.objects.get(pk=2)
|
po = models.PurchaseOrder.objects.get(pk=2)
|
||||||
|
|
||||||
url = reverse('api-po-issue', kwargs={'pk': po.pk})
|
url = reverse('api-po-issue', kwargs={'pk': po.pk})
|
||||||
@ -303,7 +303,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDownloadTest(OrderTest):
|
class PurchaseOrderDownloadTest(OrderTest):
|
||||||
"""Unit tests for downloading PurchaseOrder data via the API endpoint"""
|
"""Unit tests for downloading PurchaseOrder data via the API endpoint."""
|
||||||
|
|
||||||
required_cols = [
|
required_cols = [
|
||||||
'id',
|
'id',
|
||||||
@ -321,8 +321,7 @@ class PurchaseOrderDownloadTest(OrderTest):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_download_wrong_format(self):
|
def test_download_wrong_format(self):
|
||||||
"""Incorrect format should default raise an error"""
|
"""Incorrect format should default raise an error."""
|
||||||
|
|
||||||
url = reverse('api-po-list')
|
url = reverse('api-po-list')
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@ -334,8 +333,7 @@ class PurchaseOrderDownloadTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_download_csv(self):
|
def test_download_csv(self):
|
||||||
"""Download PurchaseOrder data as .csv"""
|
"""Download PurchaseOrder data as .csv."""
|
||||||
|
|
||||||
with self.download_file(
|
with self.download_file(
|
||||||
reverse('api-po-list'),
|
reverse('api-po-list'),
|
||||||
{
|
{
|
||||||
@ -374,7 +372,7 @@ class PurchaseOrderDownloadTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceiveTest(OrderTest):
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
"""Unit tests for receiving items against a PurchaseOrder"""
|
"""Unit tests for receiving items against a PurchaseOrder."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -392,7 +390,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
"""Test without any POST data"""
|
"""Test without any POST data."""
|
||||||
data = self.post(self.url, {}, expected_code=400).data
|
data = self.post(self.url, {}, expected_code=400).data
|
||||||
|
|
||||||
self.assertIn('This field is required', str(data['items']))
|
self.assertIn('This field is required', str(data['items']))
|
||||||
@ -402,7 +400,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_no_items(self):
|
def test_no_items(self):
|
||||||
"""Test with an empty list of items"""
|
"""Test with an empty list of items."""
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -418,7 +416,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_invalid_items(self):
|
def test_invalid_items(self):
|
||||||
"""Test than errors are returned as expected for invalid data"""
|
"""Test than errors are returned as expected for invalid data."""
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -441,7 +439,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_invalid_status(self):
|
def test_invalid_status(self):
|
||||||
"""Test with an invalid StockStatus value"""
|
"""Test with an invalid StockStatus value."""
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -463,7 +461,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_mismatched_items(self):
|
def test_mismatched_items(self):
|
||||||
"""Test for supplier parts which *do* exist but do not match the order supplier"""
|
"""Test for supplier parts which *do* exist but do not match the order supplier."""
|
||||||
data = self.post(
|
data = self.post(
|
||||||
self.url,
|
self.url,
|
||||||
{
|
{
|
||||||
@ -485,7 +483,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_null_barcode(self):
|
def test_null_barcode(self):
|
||||||
"""Test than a "null" barcode field can be provided"""
|
"""Test than a "null" barcode field can be provided."""
|
||||||
# Set stock item barcode
|
# Set stock item barcode
|
||||||
item = StockItem.objects.get(pk=1)
|
item = StockItem.objects.get(pk=1)
|
||||||
item.save()
|
item.save()
|
||||||
@ -560,7 +558,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(self.n, StockItem.objects.count())
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
def test_valid(self):
|
def test_valid(self):
|
||||||
"""Test receipt of valid data"""
|
"""Test receipt of valid data."""
|
||||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
@ -637,7 +635,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||||
|
|
||||||
def test_batch_code(self):
|
def test_batch_code(self):
|
||||||
"""Test that we can supply a 'batch code' when receiving items"""
|
"""Test that we can supply a 'batch code' when receiving items."""
|
||||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
@ -678,7 +676,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(item_2.batch, 'xyz-789')
|
self.assertEqual(item_2.batch, 'xyz-789')
|
||||||
|
|
||||||
def test_serial_numbers(self):
|
def test_serial_numbers(self):
|
||||||
"""Test that we can supply a 'serial number' when receiving items"""
|
"""Test that we can supply a 'serial number' when receiving items."""
|
||||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
@ -734,7 +732,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderTest(OrderTest):
|
class SalesOrderTest(OrderTest):
|
||||||
"""Tests for the SalesOrder API"""
|
"""Tests for the SalesOrder API."""
|
||||||
|
|
||||||
LIST_URL = reverse('api-so-list')
|
LIST_URL = reverse('api-so-list')
|
||||||
|
|
||||||
@ -757,10 +755,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
self.filter({'status': 99}, 0) # Invalid
|
self.filter({'status': 99}, 0) # Invalid
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""
|
"""Test "overdue" status."""
|
||||||
Test "overdue" status
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.filter({'overdue': True}, 0)
|
self.filter({'overdue': True}, 0)
|
||||||
self.filter({'overdue': False}, 5)
|
self.filter({'overdue': False}, 5)
|
||||||
|
|
||||||
@ -789,7 +784,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
self.get(url)
|
self.get(url)
|
||||||
|
|
||||||
def test_so_operations(self):
|
def test_so_operations(self):
|
||||||
"""Test that we can create / edit and delete a SalesOrder via the API"""
|
"""Test that we can create / edit and delete a SalesOrder via the API."""
|
||||||
n = models.SalesOrder.objects.count()
|
n = models.SalesOrder.objects.count()
|
||||||
|
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
@ -869,7 +864,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
def test_so_create(self):
|
def test_so_create(self):
|
||||||
"""Test that we can create a new SalesOrder via the API"""
|
"""Test that we can create a new SalesOrder via the API."""
|
||||||
self.assignRole('sales_order.add')
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
self.post(
|
self.post(
|
||||||
@ -883,8 +878,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_so_cancel(self):
|
def test_so_cancel(self):
|
||||||
""" Test API endpoint for cancelling a SalesOrder """
|
"""Test API endpoint for cancelling a SalesOrder."""
|
||||||
|
|
||||||
so = models.SalesOrder.objects.get(pk=1)
|
so = models.SalesOrder.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(so.status, SalesOrderStatus.PENDING)
|
self.assertEqual(so.status, SalesOrderStatus.PENDING)
|
||||||
@ -920,7 +914,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemTest(OrderTest):
|
class SalesOrderLineItemTest(OrderTest):
|
||||||
"""Tests for the SalesOrderLineItem API"""
|
"""Tests for the SalesOrderLineItem API."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
@ -998,10 +992,10 @@ class SalesOrderLineItemTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderDownloadTest(OrderTest):
|
class SalesOrderDownloadTest(OrderTest):
|
||||||
"""Unit tests for downloading SalesOrder data via the API endpoint"""
|
"""Unit tests for downloading SalesOrder data via the API endpoint."""
|
||||||
|
|
||||||
def test_download_fail(self):
|
def test_download_fail(self):
|
||||||
"""Test that downloading without the 'export' option fails"""
|
"""Test that downloading without the 'export' option fails."""
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@ -1088,7 +1082,7 @@ class SalesOrderDownloadTest(OrderTest):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateTest(OrderTest):
|
class SalesOrderAllocateTest(OrderTest):
|
||||||
"""Unit tests for allocating stock items against a SalesOrder"""
|
"""Unit tests for allocating stock items against a SalesOrder."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -1123,7 +1117,7 @@ class SalesOrderAllocateTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
"""Test POST with invalid data"""
|
"""Test POST with invalid data."""
|
||||||
# No data
|
# No data
|
||||||
response = self.post(self.url, {}, expected_code=400)
|
response = self.post(self.url, {}, expected_code=400)
|
||||||
|
|
||||||
@ -1206,7 +1200,7 @@ class SalesOrderAllocateTest(OrderTest):
|
|||||||
self.assertEqual(line.allocations.count(), 1)
|
self.assertEqual(line.allocations.count(), 1)
|
||||||
|
|
||||||
def test_shipment_complete(self):
|
def test_shipment_complete(self):
|
||||||
"""Test that we can complete a shipment via the API"""
|
"""Test that we can complete a shipment via the API."""
|
||||||
url = reverse('api-so-shipment-ship', kwargs={'pk': self.shipment.pk})
|
url = reverse('api-so-shipment-ship', kwargs={'pk': self.shipment.pk})
|
||||||
|
|
||||||
self.assertFalse(self.shipment.is_complete())
|
self.assertFalse(self.shipment.is_complete())
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for the 'order' model data migrations"""
|
"""Unit tests for the 'order' model data migrations."""
|
||||||
|
|
||||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
@ -6,13 +6,13 @@ from InvenTree.status_codes import SalesOrderStatus
|
|||||||
|
|
||||||
|
|
||||||
class TestRefIntMigrations(MigratorTestCase):
|
class TestRefIntMigrations(MigratorTestCase):
|
||||||
"""Test entire schema migration"""
|
"""Test entire schema migration."""
|
||||||
|
|
||||||
migrate_from = ('order', '0040_salesorder_target_date')
|
migrate_from = ('order', '0040_salesorder_target_date')
|
||||||
migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339')
|
migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""Create initial data set"""
|
"""Create initial data set."""
|
||||||
# Create a purchase order from a supplier
|
# Create a purchase order from a supplier
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ class TestRefIntMigrations(MigratorTestCase):
|
|||||||
print(sales_order.reference_int)
|
print(sales_order.reference_int)
|
||||||
|
|
||||||
def test_ref_field(self):
|
def test_ref_field(self):
|
||||||
"""Test that the 'reference_int' field has been created and is filled out correctly"""
|
"""Test that the 'reference_int' field has been created and is filled out correctly."""
|
||||||
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||||
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
||||||
|
|
||||||
@ -65,13 +65,13 @@ class TestRefIntMigrations(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestShipmentMigration(MigratorTestCase):
|
class TestShipmentMigration(MigratorTestCase):
|
||||||
"""Test data migration for the "SalesOrderShipment" model"""
|
"""Test data migration for the "SalesOrderShipment" model."""
|
||||||
|
|
||||||
migrate_from = ('order', '0051_auto_20211014_0623')
|
migrate_from = ('order', '0051_auto_20211014_0623')
|
||||||
migrate_to = ('order', '0055_auto_20211025_0645')
|
migrate_to = ('order', '0055_auto_20211025_0645')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""Create an initial SalesOrder"""
|
"""Create an initial SalesOrder."""
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
|
|
||||||
customer = Company.objects.create(
|
customer = Company.objects.create(
|
||||||
@ -97,7 +97,7 @@ class TestShipmentMigration(MigratorTestCase):
|
|||||||
self.old_state.apps.get_model('order', 'salesordershipment')
|
self.old_state.apps.get_model('order', 'salesordershipment')
|
||||||
|
|
||||||
def test_shipment_creation(self):
|
def test_shipment_creation(self):
|
||||||
"""Check that a SalesOrderShipment has been created"""
|
"""Check that a SalesOrderShipment has been created."""
|
||||||
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
||||||
Shipment = self.new_state.apps.get_model('order', 'salesordershipment')
|
Shipment = self.new_state.apps.get_model('order', 'salesordershipment')
|
||||||
|
|
||||||
@ -107,13 +107,13 @@ class TestShipmentMigration(MigratorTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestAdditionalLineMigration(MigratorTestCase):
|
class TestAdditionalLineMigration(MigratorTestCase):
|
||||||
"""Test entire schema migration"""
|
"""Test entire schema migration."""
|
||||||
|
|
||||||
migrate_from = ('order', '0063_alter_purchaseorderlineitem_unique_together')
|
migrate_from = ('order', '0063_alter_purchaseorderlineitem_unique_together')
|
||||||
migrate_to = ('order', '0064_purchaseorderextraline_salesorderextraline')
|
migrate_to = ('order', '0064_purchaseorderextraline_salesorderextraline')
|
||||||
|
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""Create initial data set"""
|
"""Create initial data set."""
|
||||||
# Create a purchase order from a supplier
|
# Create a purchase order from a supplier
|
||||||
Company = self.old_state.apps.get_model('company', 'company')
|
Company = self.old_state.apps.get_model('company', 'company')
|
||||||
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
|
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
|
||||||
@ -176,7 +176,7 @@ class TestAdditionalLineMigration(MigratorTestCase):
|
|||||||
# )
|
# )
|
||||||
|
|
||||||
def test_po_migration(self):
|
def test_po_migration(self):
|
||||||
"""Test that the the PO lines where converted correctly"""
|
"""Test that the the PO lines where converted correctly."""
|
||||||
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||||
for ii in range(10):
|
for ii in range(10):
|
||||||
|
|
||||||
|
@ -44,8 +44,7 @@ class SalesOrderTest(TestCase):
|
|||||||
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
|
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""Tests for overdue functionality"""
|
"""Tests for overdue functionality."""
|
||||||
|
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
|
|
||||||
# By default, order is *not* overdue as the target date is not set
|
# By default, order is *not* overdue as the target date is not set
|
||||||
|
@ -37,17 +37,17 @@ class OrderListTest(OrderViewTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderTests(OrderViewTestCase):
|
class PurchaseOrderTests(OrderViewTestCase):
|
||||||
"""Tests for PurchaseOrder views"""
|
"""Tests for PurchaseOrder views."""
|
||||||
|
|
||||||
def test_detail_view(self):
|
def test_detail_view(self):
|
||||||
""" Retrieve PO detail view """
|
"""Retrieve PO detail view."""
|
||||||
response = self.client.get(reverse('po-detail', args=(1,)))
|
response = self.client.get(reverse('po-detail', args=(1,)))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
keys = response.context.keys()
|
keys = response.context.keys()
|
||||||
self.assertIn('PurchaseOrderStatus', keys)
|
self.assertIn('PurchaseOrderStatus', keys)
|
||||||
|
|
||||||
def test_po_export(self):
|
def test_po_export(self):
|
||||||
"""Export PurchaseOrder"""
|
"""Export PurchaseOrder."""
|
||||||
response = self.client.get(reverse('po-export', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('po-export', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
# Response should be streaming-content (file download)
|
# Response should be streaming-content (file download)
|
||||||
|
@ -26,7 +26,7 @@ class OrderTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_basics(self):
|
def test_basics(self):
|
||||||
"""Basic tests e.g. repr functions etc"""
|
"""Basic tests e.g. repr functions etc."""
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
||||||
@ -38,7 +38,7 @@ class OrderTest(TestCase):
|
|||||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""Test overdue status functionality"""
|
"""Test overdue status functionality."""
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
|
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
@ -53,7 +53,7 @@ class OrderTest(TestCase):
|
|||||||
self.assertFalse(order.is_overdue)
|
self.assertFalse(order.is_overdue)
|
||||||
|
|
||||||
def test_on_order(self):
|
def test_on_order(self):
|
||||||
"""There should be 3 separate items on order for the M2x4 LPHS part"""
|
"""There should be 3 separate items on order for the M2x4 LPHS part."""
|
||||||
part = Part.objects.get(name='M2x4 LPHS')
|
part = Part.objects.get(name='M2x4 LPHS')
|
||||||
|
|
||||||
open_orders = []
|
open_orders = []
|
||||||
@ -67,7 +67,7 @@ class OrderTest(TestCase):
|
|||||||
self.assertEqual(part.on_order, 1400)
|
self.assertEqual(part.on_order, 1400)
|
||||||
|
|
||||||
def test_add_items(self):
|
def test_add_items(self):
|
||||||
"""Test functions for adding line items to an order"""
|
"""Test functions for adding line items to an order."""
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||||
@ -103,7 +103,7 @@ class OrderTest(TestCase):
|
|||||||
order.add_line_item(sku, 99)
|
order.add_line_item(sku, 99)
|
||||||
|
|
||||||
def test_pricing(self):
|
def test_pricing(self):
|
||||||
"""Test functions for adding line items to an order including price-breaks"""
|
"""Test functions for adding line items to an order including price-breaks."""
|
||||||
order = PurchaseOrder.objects.get(pk=7)
|
order = PurchaseOrder.objects.get(pk=7)
|
||||||
|
|
||||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||||
@ -135,7 +135,7 @@ class OrderTest(TestCase):
|
|||||||
self.assertEqual(order.lines.first().purchase_price.amount, 1.25)
|
self.assertEqual(order.lines.first().purchase_price.amount, 1.25)
|
||||||
|
|
||||||
def test_receive(self):
|
def test_receive(self):
|
||||||
"""Test order receiving functions"""
|
"""Test order receiving functions."""
|
||||||
part = Part.objects.get(name='M2x4 LPHS')
|
part = Part.objects.get(name='M2x4 LPHS')
|
||||||
|
|
||||||
# Receive some items
|
# Receive some items
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Django views for interacting with Order app"""
|
"""Django views for interacting with Order app."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@ -31,16 +31,14 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
|
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
|
||||||
"""List view for all purchase orders"""
|
"""List view for all purchase orders."""
|
||||||
|
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
template_name = 'order/purchase_orders.html'
|
template_name = 'order/purchase_orders.html'
|
||||||
context_object_name = 'orders'
|
context_object_name = 'orders'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Retrieve the list of purchase orders,
|
"""Retrieve the list of purchase orders, ensure that the most recent ones are returned first."""
|
||||||
ensure that the most recent ones are returned first. """
|
|
||||||
|
|
||||||
queryset = PurchaseOrder.objects.all().order_by('-creation_date')
|
queryset = PurchaseOrder.objects.all().order_by('-creation_date')
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
@ -59,7 +57,7 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""Detail view for a PurchaseOrder object"""
|
"""Detail view for a PurchaseOrder object."""
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
|
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
|
||||||
@ -72,7 +70,7 @@ class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailVi
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""Detail view for a SalesOrder object"""
|
"""Detail view for a SalesOrder object."""
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
queryset = SalesOrder.objects.all().prefetch_related('lines__allocations__item__purchase_order')
|
queryset = SalesOrder.objects.all().prefetch_related('lines__allocations__item__purchase_order')
|
||||||
@ -124,13 +122,11 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
file_manager_class = OrderFileManager
|
file_manager_class = OrderFileManager
|
||||||
|
|
||||||
def get_order(self):
|
def get_order(self):
|
||||||
"""Get order or return 404"""
|
"""Get order or return 404."""
|
||||||
|
|
||||||
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
def get_context_data(self, form, **kwargs):
|
def get_context_data(self, form, **kwargs):
|
||||||
"""Handle context data for order"""
|
"""Handle context data for order."""
|
||||||
|
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
order = self.get_order()
|
order = self.get_order()
|
||||||
@ -229,7 +225,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
row['notes'] = notes
|
row['notes'] = notes
|
||||||
|
|
||||||
def done(self, form_list, **kwargs):
|
def done(self, form_list, **kwargs):
|
||||||
"""Once all the data is in, process it to add PurchaseOrderLineItem instances to the order"""
|
"""Once all the data is in, process it to add PurchaseOrderLineItem instances to the order."""
|
||||||
order = self.get_order()
|
order = self.get_order()
|
||||||
items = self.get_clean_items()
|
items = self.get_clean_items()
|
||||||
|
|
||||||
@ -260,7 +256,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
|
|
||||||
|
|
||||||
class SalesOrderExport(AjaxView):
|
class SalesOrderExport(AjaxView):
|
||||||
"""Export a sales order
|
"""Export a sales order.
|
||||||
|
|
||||||
- File format can optionally be passed as a query parameter e.g. ?format=CSV
|
- File format can optionally be passed as a query parameter e.g. ?format=CSV
|
||||||
- Default file format is CSV
|
- Default file format is CSV
|
||||||
@ -286,7 +282,7 @@ class SalesOrderExport(AjaxView):
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExport(AjaxView):
|
class PurchaseOrderExport(AjaxView):
|
||||||
"""File download for a purchase order
|
"""File download for a purchase order.
|
||||||
|
|
||||||
- File format can be optionally passed as a query param e.g. ?format=CSV
|
- File format can be optionally passed as a query param e.g. ?format=CSV
|
||||||
- Default file format is CSV
|
- Default file format is CSV
|
||||||
@ -317,7 +313,7 @@ class PurchaseOrderExport(AjaxView):
|
|||||||
|
|
||||||
|
|
||||||
class LineItemPricing(PartPricing):
|
class LineItemPricing(PartPricing):
|
||||||
"""View for inspecting part pricing information"""
|
"""View for inspecting part pricing information."""
|
||||||
|
|
||||||
class EnhancedForm(PartPricing.form_class):
|
class EnhancedForm(PartPricing.form_class):
|
||||||
pk = IntegerField(widget=HiddenInput())
|
pk = IntegerField(widget=HiddenInput())
|
||||||
@ -361,7 +357,7 @@ class LineItemPricing(PartPricing):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_quantity(self):
|
def get_quantity(self):
|
||||||
"""Return set quantity in decimal format"""
|
"""Return set quantity in decimal format."""
|
||||||
qty = Decimal(self.request.GET.get('quantity', 1))
|
qty = Decimal(self.request.GET.get('quantity', 1))
|
||||||
if qty == 1:
|
if qty == 1:
|
||||||
return Decimal(self.request.POST.get('quantity', 1))
|
return Decimal(self.request.POST.get('quantity', 1))
|
||||||
|
@ -11,7 +11,7 @@ from stock.models import StockLocation
|
|||||||
|
|
||||||
|
|
||||||
class PartResource(ModelResource):
|
class PartResource(ModelResource):
|
||||||
""" Class for managing Part data import/export """
|
"""Class for managing Part data import/export."""
|
||||||
|
|
||||||
# ForeignKey fields
|
# ForeignKey fields
|
||||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||||
@ -49,8 +49,7 @@ class PartResource(ModelResource):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Prefetch related data for quicker access """
|
"""Prefetch related data for quicker access."""
|
||||||
|
|
||||||
query = super().get_queryset()
|
query = super().get_queryset()
|
||||||
query = query.prefetch_related(
|
query = query.prefetch_related(
|
||||||
'category',
|
'category',
|
||||||
@ -82,7 +81,7 @@ class PartAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class PartCategoryResource(ModelResource):
|
class PartCategoryResource(ModelResource):
|
||||||
""" Class for managing PartCategory data import/export """
|
"""Class for managing PartCategory data import/export."""
|
||||||
|
|
||||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||||
|
|
||||||
@ -122,9 +121,7 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class PartRelatedAdmin(admin.ModelAdmin):
|
class PartRelatedAdmin(admin.ModelAdmin):
|
||||||
"""
|
"""Class to manage PartRelated objects."""
|
||||||
Class to manage PartRelated objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
autocomplete_fields = ('part_1', 'part_2')
|
autocomplete_fields = ('part_1', 'part_2')
|
||||||
|
|
||||||
@ -158,7 +155,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class BomItemResource(ModelResource):
|
class BomItemResource(ModelResource):
|
||||||
""" Class for managing BomItem data import/export """
|
"""Class for managing BomItem data import/export."""
|
||||||
|
|
||||||
level = Field(attribute='level', readonly=True)
|
level = Field(attribute='level', readonly=True)
|
||||||
|
|
||||||
@ -189,9 +186,7 @@ class BomItemResource(ModelResource):
|
|||||||
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
|
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
|
||||||
|
|
||||||
def dehydrate_quantity(self, item):
|
def dehydrate_quantity(self, item):
|
||||||
"""
|
"""Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1")
|
||||||
Special consideration for the 'quantity' field on data export.
|
|
||||||
We do not want a spreadsheet full of "1.0000" (we'd rather "1")
|
|
||||||
|
|
||||||
Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
|
Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
|
||||||
"""
|
"""
|
||||||
@ -202,12 +197,7 @@ class BomItemResource(ModelResource):
|
|||||||
self.is_importing = kwargs.get('importing', False)
|
self.is_importing = kwargs.get('importing', False)
|
||||||
|
|
||||||
def get_fields(self, **kwargs):
|
def get_fields(self, **kwargs):
|
||||||
"""
|
"""If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in."""
|
||||||
If we are exporting for the purposes of generating
|
|
||||||
a 'bom-import' template, there are some fields which
|
|
||||||
we are not interested in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
fields = super().get_fields(**kwargs)
|
fields = super().get_fields(**kwargs)
|
||||||
|
|
||||||
# If we are not generating an "import" template,
|
# If we are not generating an "import" template,
|
||||||
@ -270,7 +260,7 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ParameterResource(ModelResource):
|
class ParameterResource(ModelResource):
|
||||||
""" Class for managing PartParameter data import/export """
|
"""Class for managing PartParameter data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user