mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-13 03:50:46 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into devOps
This commit is contained in:
@@ -18,6 +18,18 @@ jobs:
|
|||||||
- name: Check version number
|
- name: Check version number
|
||||||
run: |
|
run: |
|
||||||
python3 ci/check_version_number.py --dev
|
python3 ci/check_version_number.py --dev
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: |
|
||||||
|
cd docker
|
||||||
|
docker-compose build
|
||||||
|
docker-compose run inventree-dev-server invoke update
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
cd docker
|
||||||
|
docker-compose up -d
|
||||||
|
docker-compose run inventree-dev-server invoke wait
|
||||||
|
docker-compose run inventree-dev-server invoke test
|
||||||
|
docker-compose down
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Test that the InvenTree docker image compiles correctly
|
|
||||||
|
|
||||||
# This CI action runs on pushes to either the master or stable branches
|
|
||||||
|
|
||||||
# 1. Build the development docker image (as per the documentation)
|
|
||||||
# 2. Install requied python libs into the docker container
|
|
||||||
# 3. Launch the container
|
|
||||||
# 4. Check that the API endpoint is available
|
|
||||||
|
|
||||||
name: Docker Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'master'
|
|
||||||
- 'stable'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Build Docker Image
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
docker-compose -f docker-compose.sqlite.yml build
|
|
||||||
docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
|
|
||||||
docker-compose -f docker-compose.sqlite.yml up -d
|
|
||||||
- name: Sleepy Time
|
|
||||||
run: sleep 60
|
|
||||||
- name: Test API
|
|
||||||
run: |
|
|
||||||
pip install requests
|
|
||||||
python3 ci/check_api_endpoint.py
|
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
||||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||||
stale-issue-label: 'no-activity'
|
stale-issue-label: 'inactive'
|
||||||
stale-pr-label: 'no-activity'
|
stale-pr-label: 'inactive'
|
||||||
start-date: '2022-01-01'
|
start-date: '2022-01-01'
|
||||||
exempt-all-milestones: true
|
exempt-all-milestones: true
|
||||||
|
|||||||
@@ -16,5 +16,10 @@ jobs:
|
|||||||
- uses: actions/first-interaction@v1
|
- uses: actions/first-interaction@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
issue-message: 'Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.\nIf you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).'
|
issue-message: |
|
||||||
pr-message: 'This is your first PR, welcome!\nPlease check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.\nMake sure to document why this PR is needed and to link connected issues so we can review it faster.'
|
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.
|
||||||
|
If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
|
||||||
|
pr-message: |
|
||||||
|
This is your first PR, welcome!
|
||||||
|
Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.
|
||||||
|
Make sure to document why this PR is needed and to link connected issues so we can review it faster.
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Main JSON interface views
|
Main JSON interface views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def put(self, url, data, expected_code=None, format='json'):
|
||||||
|
"""
|
||||||
|
Issue a PUT request
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.put(url, data=data, format=format)
|
||||||
|
|
||||||
|
if expected_code is not None:
|
||||||
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def options(self, url, expected_code=None):
|
def options(self, url, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue an OPTIONS request
|
Issue an OPTIONS request
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ InvenTree API version information
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 48
|
INVENTREE_API_VERSION = 50
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
|
||||||
|
- Implement Attachments for manufacturer parts
|
||||||
|
|
||||||
|
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
|
||||||
|
- Allows filtering of plugin list by 'active' status
|
||||||
|
- Allows filtering of plugin list by 'mixin' support
|
||||||
|
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
|
||||||
|
|
||||||
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
||||||
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
||||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
update = True
|
update = True
|
||||||
|
|
||||||
# Backend currency has changed?
|
# Backend currency has changed?
|
||||||
if not base_currency == backend.base_currency:
|
if base_currency != backend.base_currency:
|
||||||
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Custom exception handling for the DRF API
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.debug import ExceptionReporter
|
||||||
|
|
||||||
|
from error_report.models import Error
|
||||||
|
|
||||||
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import serializers
|
||||||
|
import rest_framework.views as drfviews
|
||||||
|
|
||||||
|
|
||||||
|
def exception_handler(exc, context):
|
||||||
|
"""
|
||||||
|
Custom exception handler for DRF framework.
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = None
|
||||||
|
|
||||||
|
# Catch any django validation error, and re-throw a DRF validation error
|
||||||
|
if isinstance(exc, DjangoValidationError):
|
||||||
|
exc = DRFValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
|
# Default to the built-in DRF exception handler
|
||||||
|
response = drfviews.exception_handler(exc, context)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
# DRF handler did not provide a default response for this exception
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
error_detail = str(exc)
|
||||||
|
else:
|
||||||
|
error_detail = _("Error details can be found in the admin panel")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'error': type(exc).__name__,
|
||||||
|
'error_class': str(type(exc)),
|
||||||
|
'detail': error_detail,
|
||||||
|
'path': context['request'].path,
|
||||||
|
'status_code': 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = Response(response_data, status=500)
|
||||||
|
|
||||||
|
# Log the exception to the database, too
|
||||||
|
kind, info, data = sys.exc_info()
|
||||||
|
|
||||||
|
Error.objects.create(
|
||||||
|
kind=kind.__name__,
|
||||||
|
info=info,
|
||||||
|
data='\n'.join(traceback.format_exception(kind, info, data)),
|
||||||
|
path=context['request'].path,
|
||||||
|
html=ExceptionReporter(context['request'], kind, info, data).get_traceback_html(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
# Convert errors returned under the label '__all__' to 'non_field_errors'
|
||||||
|
if '__all__' in response.data:
|
||||||
|
response.data['non_field_errors'] = response.data['__all__']
|
||||||
|
del response.data['__all__']
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
""" Custom fields used in InvenTree """
|
""" Custom fields used in InvenTree """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .validators import allowable_url_schemes
|
from .validators import allowable_url_schemes
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
Helper forms which subclass Django forms to provide additional functionality
|
Helper forms which subclass Django forms to provide additional functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ def increment(n):
|
|||||||
groups = result.groups()
|
groups = result.groups()
|
||||||
|
|
||||||
# If we cannot match the regex, then simply return the provided value
|
# If we cannot match the regex, then simply return the provided value
|
||||||
if not len(groups) == 2:
|
if len(groups) != 2:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
prefix, number = groups
|
prefix, number = groups
|
||||||
@@ -536,7 +536,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
|
|||||||
raise ValidationError([_("No serial numbers found")])
|
raise ValidationError([_("No serial numbers found")])
|
||||||
|
|
||||||
# The number of extracted serial numbers must match the expected quantity
|
# The number of extracted serial numbers must match the expected quantity
|
||||||
if not expected_quantity == len(numbers):
|
if expected_quantity != len(numbers):
|
||||||
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
|
||||||
|
|
||||||
return numbers
|
return numbers
|
||||||
@@ -575,7 +575,7 @@ def validateFilterString(value, model=None):
|
|||||||
|
|
||||||
pair = group.split('=')
|
pair = group.split('=')
|
||||||
|
|
||||||
if not len(pair) == 2:
|
if len(pair) != 2:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Invalid group: {g}".format(g=group)
|
"Invalid group: {g}".format(g=group)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@@ -250,7 +246,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
field_info = super().get_field_info(field)
|
field_info = super().get_field_info(field)
|
||||||
|
|
||||||
# If a default value is specified for the serializer field, add it!
|
# If a default value is specified for the serializer field, add it!
|
||||||
if 'default' not in field_info and not field.default == empty:
|
if 'default' not in field_info and field.default != empty:
|
||||||
field_info['default'] = field.get_default()
|
field_info['default'] = field.get_default()
|
||||||
|
|
||||||
# Force non-nullable fields to read as "required"
|
# Force non-nullable fields to read as "required"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
Generic models which provide extra functionality over base Django model types.
|
Generic models which provide extra functionality over base Django model types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -259,7 +257,7 @@ class InvenTreeAttachment(models.Model):
|
|||||||
new_file = os.path.abspath(new_file)
|
new_file = os.path.abspath(new_file)
|
||||||
|
|
||||||
# Check that there are no directory tricks going on...
|
# Check that there are no directory tricks going on...
|
||||||
if not os.path.dirname(new_file) == attachment_dir:
|
if os.path.dirname(new_file) != attachment_dir:
|
||||||
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
|
||||||
raise ValidationError(_("Invalid attachment directory"))
|
raise ValidationError(_("Invalid attachment directory"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
import users.models
|
import users.models
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Serializers used in various InvenTree apps
|
Serializers used in various InvenTree apps
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tablib
|
import tablib
|
||||||
|
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ TEMPLATES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
|
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
|
||||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
'rest_framework.authentication.BasicAuthentication',
|
||||||
@@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
|
||||||
|
|
||||||
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover
|
if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
|
||||||
logger.warning("extra_url_schemes not correctly formatted")
|
logger.warning("extra_url_schemes not correctly formatted")
|
||||||
EXTRA_URL_SCHEMES = []
|
EXTRA_URL_SCHEMES = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -11,6 +9,8 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
from django.core import mail as django_mail
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
@@ -52,6 +52,15 @@ def schedule_task(taskname, **kwargs):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def raise_warning(msg):
|
||||||
|
"""Log and raise a warning"""
|
||||||
|
logger.warning(msg)
|
||||||
|
|
||||||
|
# If testing is running raise a warning that can be asserted
|
||||||
|
if settings.TESTING:
|
||||||
|
warnings.warn(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.
|
Create an AsyncTask if workers are running.
|
||||||
@@ -67,28 +76,38 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
from InvenTree.status import is_worker_running
|
from InvenTree.status import is_worker_running
|
||||||
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
|
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
||||||
|
return
|
||||||
|
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||||
|
raise_warning(f"Could not offload task '{taskname}' - database not ready")
|
||||||
|
|
||||||
if is_worker_running() and not force_sync: # pragma: no cover
|
if is_worker_running() and not force_sync: # pragma: no cover
|
||||||
# Running as asynchronous task
|
# Running as asynchronous task
|
||||||
try:
|
try:
|
||||||
task = AsyncTask(taskname, *args, **kwargs)
|
task = AsyncTask(taskname, *args, **kwargs)
|
||||||
task.run()
|
task.run()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning(f"WARNING: '{taskname}' not started - Function not found")
|
raise_warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||||
|
else:
|
||||||
|
|
||||||
|
if callable(taskname):
|
||||||
|
# function was passed - use that
|
||||||
|
_func = taskname
|
||||||
else:
|
else:
|
||||||
# Split path
|
# Split path
|
||||||
try:
|
try:
|
||||||
app, mod, func = taskname.split('.')
|
app, mod, func = taskname.split('.')
|
||||||
app_mod = app + '.' + mod
|
app_mod = app + '.' + mod
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
raise_warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Import module from app
|
# Import module from app
|
||||||
try:
|
try:
|
||||||
_mod = importlib.import_module(app_mod)
|
_mod = importlib.import_module(app_mod)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
|
raise_warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Retrieve function
|
# Retrieve function
|
||||||
@@ -102,17 +121,11 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
|||||||
if not _func:
|
if not _func:
|
||||||
_func = eval(func) # pragma: no cover
|
_func = eval(func) # pragma: no cover
|
||||||
except NameError:
|
except NameError:
|
||||||
logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
|
raise_warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Workers are not running: run it as synchronous task
|
# Workers are not running: run it as synchronous task
|
||||||
_func(*args, **kwargs)
|
_func(*args, **kwargs)
|
||||||
|
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
|
||||||
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
|
|
||||||
return
|
|
||||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
|
||||||
logger.warning(f"Could not offload task '{taskname}' - database not ready")
|
|
||||||
|
|
||||||
|
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
@@ -126,8 +139,8 @@ def heartbeat():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
|
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
threshold = timezone.now() - timedelta(minutes=30)
|
threshold = timezone.now() - timedelta(minutes=30)
|
||||||
@@ -204,26 +217,26 @@ def check_for_updates():
|
|||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||||
|
|
||||||
if not response.status_code == 200:
|
if response.status_code != 200:
|
||||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
|
||||||
|
|
||||||
data = json.loads(response.text)
|
data = json.loads(response.text)
|
||||||
|
|
||||||
tag = data.get('tag_name', None)
|
tag = data.get('tag_name', None)
|
||||||
|
|
||||||
if not tag:
|
if not tag:
|
||||||
raise ValueError("'tag_name' missing from GitHub response")
|
raise ValueError("'tag_name' missing from GitHub response") # pragma: no cover
|
||||||
|
|
||||||
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
|
||||||
|
|
||||||
if not len(match.groups()) == 3:
|
if len(match.groups()) != 3: # pragma: no cover
|
||||||
logger.warning(f"Version '{tag}' did not match expected pattern")
|
logger.warning(f"Version '{tag}' did not match expected pattern")
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_version = [int(x) for x in match.groups()]
|
latest_version = [int(x) for x in match.groups()]
|
||||||
|
|
||||||
if not len(latest_version) == 3:
|
if len(latest_version) != 3:
|
||||||
raise ValueError(f"Version '{tag}' is not correct format")
|
raise ValueError(f"Version '{tag}' is not correct format") # pragma: no cover
|
||||||
|
|
||||||
logger.info(f"Latest InvenTree version: '{tag}'")
|
logger.info(f"Latest InvenTree version: '{tag}'")
|
||||||
|
|
||||||
@@ -288,7 +301,7 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
|
|||||||
recipients = [recipients]
|
recipients = [recipients]
|
||||||
|
|
||||||
offload_task(
|
offload_task(
|
||||||
'django.core.mail.send_mail',
|
django_mail.send_mail,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
from_email,
|
from_email,
|
||||||
|
|||||||
@@ -2,10 +2,20 @@
|
|||||||
Unit tests for task management
|
Unit tests for task management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
|
|
||||||
|
from error_report.models import Error
|
||||||
|
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
|
threshold = timezone.now() - timedelta(days=30)
|
||||||
|
threshold_low = threshold - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskTests(TestCase):
|
class ScheduledTaskTests(TestCase):
|
||||||
@@ -41,3 +51,79 @@ class ScheduledTaskTests(TestCase):
|
|||||||
# But the 'minutes' should have been updated
|
# But the 'minutes' should have been updated
|
||||||
t = Schedule.objects.get(func=task)
|
t = Schedule.objects.get(func=task)
|
||||||
self.assertEqual(t.minutes, 5)
|
self.assertEqual(t.minutes, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def get_result():
|
||||||
|
"""Demo function for test_offloading"""
|
||||||
|
return 'abc'
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeTaskTests(TestCase):
|
||||||
|
"""Unit tests for tasks"""
|
||||||
|
|
||||||
|
def test_offloading(self):
|
||||||
|
"""Test task offloading"""
|
||||||
|
|
||||||
|
# Run with function ref
|
||||||
|
InvenTree.tasks.offload_task(get_result)
|
||||||
|
|
||||||
|
# Run with string ref
|
||||||
|
InvenTree.tasks.offload_task('InvenTree.test_tasks.get_result')
|
||||||
|
|
||||||
|
# Error runs
|
||||||
|
# Malformed taskname
|
||||||
|
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"):
|
||||||
|
InvenTree.tasks.offload_task('InvenTree')
|
||||||
|
|
||||||
|
# Non exsistent app
|
||||||
|
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"):
|
||||||
|
InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter')
|
||||||
|
|
||||||
|
# Non exsistent function
|
||||||
|
with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"):
|
||||||
|
InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist')
|
||||||
|
|
||||||
|
def test_task_hearbeat(self):
|
||||||
|
"""Test the task heartbeat"""
|
||||||
|
InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat)
|
||||||
|
|
||||||
|
def test_task_delete_successful_tasks(self):
|
||||||
|
"""Test the task delete_successful_tasks"""
|
||||||
|
from django_q.models import Success
|
||||||
|
|
||||||
|
Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low)
|
||||||
|
InvenTree.tasks.offload_task(InvenTree.tasks.delete_successful_tasks)
|
||||||
|
results = Success.objects.filter(started__lte=threshold)
|
||||||
|
self.assertEqual(len(results), 0)
|
||||||
|
|
||||||
|
def test_task_delete_old_error_logs(self):
|
||||||
|
"""Test the task delete_old_error_logs"""
|
||||||
|
|
||||||
|
# Create error
|
||||||
|
error_obj = Error.objects.create()
|
||||||
|
error_obj.when = threshold_low
|
||||||
|
error_obj.save()
|
||||||
|
|
||||||
|
# Check that it is not empty
|
||||||
|
errors = Error.objects.filter(when__lte=threshold,)
|
||||||
|
self.assertNotEqual(len(errors), 0)
|
||||||
|
|
||||||
|
# Run action
|
||||||
|
InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_error_logs)
|
||||||
|
|
||||||
|
# Check that it is empty again
|
||||||
|
errors = Error.objects.filter(when__lte=threshold,)
|
||||||
|
self.assertEqual(len(errors), 0)
|
||||||
|
|
||||||
|
def test_task_check_for_updates(self):
|
||||||
|
"""Test the task check_for_updates"""
|
||||||
|
# Check that setting should be empty
|
||||||
|
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
||||||
|
|
||||||
|
# Get new version
|
||||||
|
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
|
||||||
|
|
||||||
|
# Check that setting is not empty
|
||||||
|
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION')
|
||||||
|
self.assertNotEqual(response, '')
|
||||||
|
self.assertTrue(bool(response))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from test.support import EnvironmentVarGuard
|
import os
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
@@ -449,17 +451,20 @@ class TestSettings(TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user_mdl = get_user_model()
|
self.user_mdl = get_user_model()
|
||||||
self.env = EnvironmentVarGuard()
|
|
||||||
|
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
user = get_user_model()
|
user = get_user_model()
|
||||||
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
||||||
self.client.login(username='testuser1', password='password1')
|
self.client.login(username='testuser1', password='password1')
|
||||||
|
|
||||||
def run_reload(self):
|
def in_env_context(self, envs={}):
|
||||||
|
"""Patch the env to include the given dict"""
|
||||||
|
return mock.patch.dict(os.environ, envs)
|
||||||
|
|
||||||
|
def run_reload(self, envs={}):
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
with self.env:
|
with self.in_env_context(envs):
|
||||||
settings.USER_ADDED = False
|
settings.USER_ADDED = False
|
||||||
registry.reload_plugins()
|
registry.reload_plugins()
|
||||||
|
|
||||||
@@ -475,25 +480,28 @@ class TestSettings(TestCase):
|
|||||||
self.assertEqual(user_count(), 1)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# not enough set
|
# not enough set
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
self.run_reload({
|
||||||
self.run_reload()
|
'INVENTREE_ADMIN_USER': 'admin'
|
||||||
|
})
|
||||||
self.assertEqual(user_count(), 1)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# enough set
|
# enough set
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
self.run_reload({
|
||||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
'INVENTREE_ADMIN_USER': 'admin', # set username
|
||||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email
|
||||||
self.run_reload()
|
'INVENTREE_ADMIN_PASSWORD': 'password123' # set password
|
||||||
|
})
|
||||||
self.assertEqual(user_count(), 2)
|
self.assertEqual(user_count(), 2)
|
||||||
|
|
||||||
# create user manually
|
# create user manually
|
||||||
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
self.assertEqual(user_count(), 3)
|
self.assertEqual(user_count(), 3)
|
||||||
# check it will not be created again
|
# check it will not be created again
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
|
self.run_reload({
|
||||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
|
'INVENTREE_ADMIN_USER': 'testuser',
|
||||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
|
'INVENTREE_ADMIN_EMAIL': 'test@testing.com',
|
||||||
self.run_reload()
|
'INVENTREE_ADMIN_PASSWORD': 'password',
|
||||||
|
})
|
||||||
self.assertEqual(user_count(), 3)
|
self.assertEqual(user_count(), 3)
|
||||||
|
|
||||||
# make sure to clean up
|
# make sure to clean up
|
||||||
@@ -517,20 +525,30 @@ class TestSettings(TestCase):
|
|||||||
|
|
||||||
def test_helpers_cfg_file(self):
|
def test_helpers_cfg_file(self):
|
||||||
# normal run - not configured
|
# normal run - not configured
|
||||||
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
|
|
||||||
|
valid = [
|
||||||
|
'inventree/config.yaml',
|
||||||
|
'inventree/dev/config.yaml',
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertTrue(any([opt in config.get_config_file().lower() for opt in valid]))
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with self.env:
|
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||||
self.env.set('INVENTREE_CONFIG_FILE', 'my_special_conf.yaml')
|
self.assertIn('inventree/inventree/my_special_conf.yaml', config.get_config_file().lower())
|
||||||
self.assertIn('InvenTree/InvenTree/my_special_conf.yaml', config.get_config_file())
|
|
||||||
|
|
||||||
def test_helpers_plugin_file(self):
|
def test_helpers_plugin_file(self):
|
||||||
# normal run - not configured
|
# normal run - not configured
|
||||||
self.assertIn('InvenTree/InvenTree/plugins.txt', config.get_plugin_file())
|
|
||||||
|
valid = [
|
||||||
|
'inventree/plugins.txt',
|
||||||
|
'inventree/dev/plugins.txt',
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertTrue(any([opt in config.get_plugin_file().lower() for opt in valid]))
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with self.env:
|
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||||
self.env.set('INVENTREE_PLUGIN_FILE', 'my_special_plugins.txt')
|
|
||||||
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
||||||
|
|
||||||
def test_helpers_setting(self):
|
def test_helpers_setting(self):
|
||||||
@@ -539,8 +557,7 @@ class TestSettings(TestCase):
|
|||||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with self.env:
|
with self.in_env_context({TEST_ENV_NAME: '321'}):
|
||||||
self.env.set(TEST_ENV_NAME, '321')
|
|
||||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ In particular these views provide base functionality for rendering Django forms
|
|||||||
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -627,7 +625,7 @@ class SetPasswordView(AjaxUpdateView):
|
|||||||
if valid:
|
if valid:
|
||||||
# Passwords must match
|
# Passwords must match
|
||||||
|
|
||||||
if not p1 == p2:
|
if p1 != p2:
|
||||||
error = _('Password fields must match')
|
error = _('Password fields must match')
|
||||||
form.add_error('enter_password', error)
|
form.add_error('enter_password', error)
|
||||||
form.add_error('confirm_password', error)
|
form.add_error('confirm_password', error)
|
||||||
@@ -797,13 +795,9 @@ class CurrencyRefreshView(RedirectView):
|
|||||||
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
|
from InvenTree.tasks import offload_task, update_exchange_rates
|
||||||
|
|
||||||
# Define associated task from InvenTree.tasks list of methods
|
offload_task(update_exchange_rates, force_sync=True)
|
||||||
taskname = 'InvenTree.tasks.update_exchange_rates'
|
|
||||||
|
|
||||||
# Run it
|
|
||||||
offload_task(taskname, force_sync=True)
|
|
||||||
|
|
||||||
return redirect(reverse_lazy('settings'))
|
return redirect(reverse_lazy('settings'))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
JSON API for the Build app
|
JSON API for the Build app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from rest_framework import filters, generics
|
from rest_framework import filters, generics
|
||||||
@@ -285,6 +282,13 @@ class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
|||||||
API endpoint for deleting multiple build outputs
|
API endpoint for deleting multiple build outputs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
ctx['to_complete'] = False
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
Django Forms for interacting with Build objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
Build database model definitions
|
Build database model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import decimal
|
import decimal
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -777,7 +775,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
if not output.is_building:
|
if not output.is_building:
|
||||||
raise ValidationError(_("Build output is already completed"))
|
raise ValidationError(_("Build output is already completed"))
|
||||||
|
|
||||||
if not output.build == self:
|
if output.build != self:
|
||||||
raise ValidationError(_("Build output does not match Build Order"))
|
raise ValidationError(_("Build output does not match Build Order"))
|
||||||
|
|
||||||
# Unallocate all build items against the output
|
# Unallocate all build items against the output
|
||||||
@@ -1141,12 +1139,13 @@ 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
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
# A new Build has just been created
|
# A new Build has just been created
|
||||||
|
|
||||||
# Run checks on required parts
|
# Run checks on required parts
|
||||||
InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance)
|
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
|
||||||
|
|
||||||
|
|
||||||
class BuildOrderAttachment(InvenTreeAttachment):
|
class BuildOrderAttachment(InvenTreeAttachment):
|
||||||
@@ -1240,7 +1239,7 @@ class BuildItem(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Quantity must be 1 for serialized stock
|
# Quantity must be 1 for serialized stock
|
||||||
if self.stock_item.serialized and not self.quantity == 1:
|
if self.stock_item.serialized and self.quantity != 1:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'quantity': _('Quantity must be 1 for serialized stock')
|
'quantity': _('Quantity must be 1 for serialized stock')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
JSON serializers for Build API
|
JSON serializers for Build API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -202,7 +199,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def validate_quantity(self, quantity):
|
def validate_quantity(self, quantity):
|
||||||
|
|
||||||
if quantity < 0:
|
if quantity <= 0:
|
||||||
raise ValidationError(_("Quantity must be greater than zero"))
|
raise ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
part = self.get_part()
|
part = self.get_part()
|
||||||
@@ -212,7 +209,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
if part.trackable:
|
if part.trackable:
|
||||||
raise ValidationError(_("Integer quantity required for trackable parts"))
|
raise ValidationError(_("Integer quantity required for trackable parts"))
|
||||||
|
|
||||||
if part.has_trackable_parts():
|
if part.has_trackable_parts:
|
||||||
raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts"))
|
raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts"))
|
||||||
|
|
||||||
return quantity
|
return quantity
|
||||||
@@ -235,7 +232,6 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
serial_numbers = serial_numbers.strip()
|
serial_numbers = serial_numbers.strip()
|
||||||
|
|
||||||
# TODO: Field level validation necessary here?
|
|
||||||
return serial_numbers
|
return serial_numbers
|
||||||
|
|
||||||
auto_allocate = serializers.BooleanField(
|
auto_allocate = serializers.BooleanField(
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|||||||
+209
-3
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -305,6 +302,215 @@ class BuildTest(BuildAPITest):
|
|||||||
|
|
||||||
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
||||||
|
|
||||||
|
def test_create_delete_output(self):
|
||||||
|
"""
|
||||||
|
Test that we can create and delete build outputs via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
bo = Build.objects.get(pk=1)
|
||||||
|
|
||||||
|
n_outputs = bo.output_count
|
||||||
|
|
||||||
|
create_url = reverse('api-build-output-create', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
# Attempt to create outputs with invalid data
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': 'not a number',
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('A valid number is required', str(response.data))
|
||||||
|
|
||||||
|
for q in [-100, -10.3, 0]:
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': q,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if q == 0:
|
||||||
|
self.assertIn('Quantity must be greater than zero', str(response.data))
|
||||||
|
else:
|
||||||
|
self.assertIn('Ensure this value is greater than or equal to 0', str(response.data))
|
||||||
|
|
||||||
|
# Mark the part being built as 'trackable' (requires integer quantity)
|
||||||
|
bo.part.trackable = True
|
||||||
|
bo.part.save()
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': 12.3,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Integer quantity required for trackable parts', str(response.data))
|
||||||
|
|
||||||
|
# Erroneous serial numbers
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': 5,
|
||||||
|
'serial_numbers': '1, 2, 3, 4, 5, 6',
|
||||||
|
'batch': 'my-batch',
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Number of unique serial numbers (6) must match quantity (5)', str(response.data))
|
||||||
|
|
||||||
|
# At this point, no new build outputs should have been created
|
||||||
|
self.assertEqual(n_outputs, bo.output_count)
|
||||||
|
|
||||||
|
# Now, create with *good* data
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': 5,
|
||||||
|
'serial_numbers': '1, 2, 3, 4, 5',
|
||||||
|
'batch': 'my-batch',
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5 new outputs have been created
|
||||||
|
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||||
|
|
||||||
|
# Attempt to create with identical serial numbers
|
||||||
|
response = self.post(
|
||||||
|
create_url,
|
||||||
|
{
|
||||||
|
'quantity': 3,
|
||||||
|
'serial_numbers': '1-3',
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('The following serial numbers already exist : 1,2,3', str(response.data))
|
||||||
|
|
||||||
|
# Double check no new outputs have been created
|
||||||
|
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||||
|
|
||||||
|
# Now, let's delete each build output individually via the API
|
||||||
|
outputs = bo.build_outputs.all()
|
||||||
|
|
||||||
|
delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
delete_url,
|
||||||
|
{
|
||||||
|
'outputs': [],
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('A list of build outputs must be provided', str(response.data))
|
||||||
|
|
||||||
|
# Mark 1 build output as complete
|
||||||
|
bo.complete_build_output(outputs[0], self.user)
|
||||||
|
|
||||||
|
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||||
|
self.assertEqual(1, bo.complete_count)
|
||||||
|
|
||||||
|
# Delete all outputs at once
|
||||||
|
# Note: One has been completed, so this should fail!
|
||||||
|
response = self.post(
|
||||||
|
delete_url,
|
||||||
|
{
|
||||||
|
'outputs': [
|
||||||
|
{
|
||||||
|
'output': output.pk,
|
||||||
|
} for output in outputs
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('This build output has already been completed', str(response.data))
|
||||||
|
|
||||||
|
# No change to the build outputs
|
||||||
|
self.assertEqual(n_outputs + 5, bo.output_count)
|
||||||
|
self.assertEqual(1, bo.complete_count)
|
||||||
|
|
||||||
|
# Let's delete 2 build outputs
|
||||||
|
response = self.post(
|
||||||
|
delete_url,
|
||||||
|
{
|
||||||
|
'outputs': [
|
||||||
|
{
|
||||||
|
'output': output.pk,
|
||||||
|
} for output in outputs[1:3]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two build outputs have been removed
|
||||||
|
self.assertEqual(n_outputs + 3, bo.output_count)
|
||||||
|
self.assertEqual(1, bo.complete_count)
|
||||||
|
|
||||||
|
# Tests for BuildOutputComplete serializer
|
||||||
|
complete_url = reverse('api-build-output-complete', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
# Let's mark the remaining outputs as complete
|
||||||
|
response = self.post(
|
||||||
|
complete_url,
|
||||||
|
{
|
||||||
|
'outputs': [],
|
||||||
|
'location': 4,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('A list of build outputs must be provided', str(response.data))
|
||||||
|
|
||||||
|
for output in outputs[3:]:
|
||||||
|
output.refresh_from_db()
|
||||||
|
self.assertTrue(output.is_building)
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
complete_url,
|
||||||
|
{
|
||||||
|
'outputs': [
|
||||||
|
{
|
||||||
|
'output': output.pk
|
||||||
|
} for output in outputs[3:]
|
||||||
|
],
|
||||||
|
'location': 4,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the outputs have been completed
|
||||||
|
self.assertEqual(3, bo.complete_count)
|
||||||
|
|
||||||
|
for output in outputs[3:]:
|
||||||
|
output.refresh_from_db()
|
||||||
|
self.assertEqual(output.location.pk, 4)
|
||||||
|
self.assertFalse(output.is_building)
|
||||||
|
|
||||||
|
# Try again, with an output which has already been completed
|
||||||
|
response = self.post(
|
||||||
|
complete_url,
|
||||||
|
{
|
||||||
|
'outputs': [
|
||||||
|
{
|
||||||
|
'output': outputs.last().pk,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('This build output has already been completed', str(response.data))
|
||||||
|
|
||||||
|
|
||||||
class BuildAllocationTest(BuildAPITest):
|
class BuildAllocationTest(BuildAPITest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with Build objects
|
Django views for interacting with Build objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Provides a JSON API for common components.
|
Provides a JSON API for common components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.http.response import HttpResponse
|
from django.http.response import HttpResponse
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class FileManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Excel import casts number-looking-items into floats, which is annoying
|
# Excel import casts number-looking-items into floats, which is annoying
|
||||||
if item == int(item) and not str(item) == str(int(item)):
|
if item == int(item) and str(item) != str(int(item)):
|
||||||
data[idx] = int(item)
|
data[idx] = int(item)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django forms for interacting with common objects
|
Django forms for interacting with common objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ Common database model definitions.
|
|||||||
These models are 'generic' and do not fit a particular business logic object.
|
These models are 'generic' and do not fit a particular business logic object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import decimal
|
import decimal
|
||||||
import math
|
import math
|
||||||
@@ -1802,10 +1799,8 @@ class WebhookEndpoint(models.Model):
|
|||||||
def process_webhook(self):
|
def process_webhook(self):
|
||||||
if self.token:
|
if self.token:
|
||||||
self.verify = VerificationMethod.TOKEN
|
self.verify = VerificationMethod.TOKEN
|
||||||
# TODO make a object-setting
|
|
||||||
if self.secret:
|
if self.secret:
|
||||||
self.verify = VerificationMethod.HMAC
|
self.verify = VerificationMethod.HMAC
|
||||||
# TODO make a object-setting
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_token(self, payload, headers, request):
|
def validate_token(self, payload, headers, request):
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
JSON serializers for common components
|
JSON serializers for common components
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
from InvenTree.helpers import get_objectreference
|
from InvenTree.helpers import get_objectreference
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
User-configurable settings for the common app
|
User-configurable settings for the common app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod, storage
|
from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod, storage
|
||||||
from plugin.models import NotificationUserSetting
|
from plugin.models import NotificationUserSetting
|
||||||
from part.test_part import BaseNotificationIntegrationTest
|
from part.test_part import BaseNotificationIntegrationTest
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from common.models import NotificationEntry
|
from common.models import NotificationEntry
|
||||||
|
from . import tasks as common_tasks
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
|
||||||
|
|
||||||
@@ -14,4 +15,4 @@ class TaskTest(TestCase):
|
|||||||
|
|
||||||
# check empty run
|
# check empty run
|
||||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||||
offload_task('common.tasks.delete_old_notifications',)
|
offload_task(common_tasks.delete_old_notifications,)
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for the views associated with the 'common' app
|
Unit tests for the views associated with the 'common' app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -133,7 +132,7 @@ class SettingsTest(TestCase):
|
|||||||
if description is None:
|
if description is None:
|
||||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
|
||||||
|
|
||||||
if not key == key.upper():
|
if key != key.upper():
|
||||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with common models
|
Django views for interacting with common models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
@@ -11,7 +8,7 @@ import import_export.widgets as widgets
|
|||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
from .models import SupplierPriceBreak
|
from .models import SupplierPriceBreak
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
@@ -112,6 +109,16 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('part', 'manufacturer',)
|
autocomplete_fields = ('part', 'manufacturer',)
|
||||||
|
|
||||||
|
|
||||||
|
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin class for ManufacturerPartAttachment model
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = ('manufacturer_part', 'attachment', 'comment')
|
||||||
|
|
||||||
|
autocomplete_fields = ('manufacturer_part',)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterResource(ModelResource):
|
class ManufacturerPartParameterResource(ModelResource):
|
||||||
"""
|
"""
|
||||||
Class for managing ManufacturerPartParameter data import/export
|
Class for managing ManufacturerPartParameter data import/export
|
||||||
@@ -178,4 +185,5 @@ admin.site.register(SupplierPart, SupplierPartAdmin)
|
|||||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||||
|
|
||||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||||
|
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
|
||||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Provides a JSON API for the Company app
|
Provides a JSON API for the Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
@@ -15,13 +12,14 @@ from django.urls import include, re_path
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
from InvenTree.api import AttachmentMixin
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from .serializers import CompanySerializer
|
from .serializers import CompanySerializer
|
||||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
from .serializers import ManufacturerPartSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer
|
||||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +161,32 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = ManufacturerPartSerializer
|
serializer_class = ManufacturerPartSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = ManufacturerPartAttachment.objects.all()
|
||||||
|
serializer_class = ManufacturerPartAttachmentSerializer
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend,
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'manufacturer_part',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
Detail endpooint for ManufacturerPartAttachment model
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = ManufacturerPartAttachment.objects.all()
|
||||||
|
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.
|
||||||
@@ -390,6 +414,12 @@ class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
manufacturer_part_api_urls = [
|
manufacturer_part_api_urls = [
|
||||||
|
|
||||||
|
# Base URL for ManufacturerPartAttachment API endpoints
|
||||||
|
re_path(r'^attachment/', include([
|
||||||
|
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
|
||||||
|
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
re_path(r'^parameter/', include([
|
re_path(r'^parameter/', include([
|
||||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Company app
|
Django Forms for interacting with Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-01 12:57
|
||||||
|
|
||||||
|
import InvenTree.fields
|
||||||
|
import InvenTree.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('company', '0042_supplierpricebreak_updated'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ManufacturerPartAttachment',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||||
|
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||||
|
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||||
|
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||||
|
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
|
||||||
|
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
Company database model definitions
|
Company database model definitions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -25,6 +22,7 @@ from stdimage.models import StdImageField
|
|||||||
|
|
||||||
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
from InvenTree.models import InvenTreeAttachment
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
import InvenTree.validators
|
import InvenTree.validators
|
||||||
@@ -383,6 +381,22 @@ class ManufacturerPart(models.Model):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class ManufacturerPartAttachment(InvenTreeAttachment):
|
||||||
|
"""
|
||||||
|
Model for storing file attachments against a ManufacturerPart object
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_api_url():
|
||||||
|
return reverse('api-manufacturer-part-attachment-list')
|
||||||
|
|
||||||
|
def getSubdir(self):
|
||||||
|
return os.path.join("manufacturer_part_files", str(self.manufacturer_part.id))
|
||||||
|
|
||||||
|
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
|
||||||
|
verbose_name=_('Manufacturer Part'), related_name='attachments')
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -494,7 +508,7 @@ class SupplierPart(models.Model):
|
|||||||
# Ensure that the linked manufacturer_part points to the same part!
|
# Ensure that the linked manufacturer_part points to the same part!
|
||||||
if self.manufacturer_part and self.part:
|
if self.manufacturer_part and self.part:
|
||||||
|
|
||||||
if not self.manufacturer_part.part == self.part:
|
if self.manufacturer_part.part != self.part:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from InvenTree.serializers import InvenTreeImageSerializerField
|
from InvenTree.serializers import InvenTreeImageSerializerField
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
@@ -16,7 +17,7 @@ from InvenTree.serializers import InvenTreeMoneySerializer
|
|||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
@@ -142,6 +143,29 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for the ManufacturerPartAttachment class
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ManufacturerPartAttachment
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'manufacturer_part',
|
||||||
|
'attachment',
|
||||||
|
'filename',
|
||||||
|
'link',
|
||||||
|
'comment',
|
||||||
|
'upload_date',
|
||||||
|
]
|
||||||
|
|
||||||
|
read_only_fields = [
|
||||||
|
'upload_date',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the ManufacturerPartParameter model
|
Serializer for the ManufacturerPartParameter model
|
||||||
|
|||||||
@@ -144,6 +144,21 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='panel panel-hidden' id='panel-attachments'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Attachments" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
{% include "attachment_button.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
{% include "attachment_table.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-parameters'>
|
<div class='panel panel-hidden' id='panel-parameters'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
@@ -178,6 +193,34 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
onPanelLoad("attachments", function() {
|
||||||
|
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
|
||||||
|
filters: {
|
||||||
|
manufacturer_part: {{ part.pk }},
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
manufacturer_part: {
|
||||||
|
value: {{ part.pk }},
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
enableDragAndDrop(
|
||||||
|
'#attachment-dropzone',
|
||||||
|
'{% url "api-manufacturer-part-attachment-list" %}',
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
manufacturer_part: {{ part.id }},
|
||||||
|
},
|
||||||
|
label: 'attachment',
|
||||||
|
success: function(data, status, xhr) {
|
||||||
|
reloadAttachmentTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function reloadParameters() {
|
function reloadParameters() {
|
||||||
$("#parameter-table").bootstrapTable("refresh");
|
$("#parameter-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
|
|
||||||
{% trans "Parameters" as text %}
|
{% trans "Parameters" as text %}
|
||||||
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
|
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
|
||||||
|
{% trans "Attachments" as text %}
|
||||||
|
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||||
{% trans "Supplier Parts" as text %}
|
{% trans "Supplier Parts" as text %}
|
||||||
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
""" Unit tests for Company views (see views.py) """
|
""" Unit tests for Company views (see views.py) """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
Django views for interacting with Company app
|
Django views for interacting with Company app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
@@ -162,7 +158,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
# Check for valid response code
|
# Check for valid response code
|
||||||
if not response.status_code == 200:
|
if response.status_code != 200:
|
||||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
@@ -24,6 +21,7 @@ from plugin.registry import registry
|
|||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from plugin.base.label import label as plugin_label
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||||
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
|
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
|
||||||
@@ -156,7 +154,7 @@ class LabelPrintMixin:
|
|||||||
|
|
||||||
# Offload a background task to print the provided label
|
# Offload a background task to print the provided label
|
||||||
offload_task(
|
offload_task(
|
||||||
'plugin.base.label.label.print_label',
|
plugin_label.print_label,
|
||||||
plugin.plugin_slug(),
|
plugin.plugin_slug(),
|
||||||
image,
|
image,
|
||||||
label_instance=label_instance,
|
label_instance=label_instance,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
|
|||||||
# File already exists - let's see if it is the "same",
|
# File already exists - let's see if it is the "same",
|
||||||
# or if we need to overwrite it with a newer copy!
|
# or if we need to overwrite it with a newer copy!
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
|
|||||||
if os.path.exists(dst_file):
|
if os.path.exists(dst_file):
|
||||||
# File already exists - let's see if it is the "same"
|
# File already exists - let's see if it is the "same"
|
||||||
|
|
||||||
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover
|
if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
|
||||||
logger.info(f"Hash differs for '{filename}'")
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
to_copy = True
|
to_copy = True
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Label printing models
|
Label printing models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# Tests for labels
|
# Tests for labels
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# Tests for labels
|
# Tests for labels
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1470
-1455
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1292
-1265
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1369
-1354
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1358
-1343
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1338
-1323
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
+1337
-1322
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|||||||
+30
-7
@@ -2,9 +2,6 @@
|
|||||||
JSON API for the Order app
|
JSON API for the Order app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.db.models import Q, F
|
from django.db.models import Q, F
|
||||||
|
|
||||||
@@ -27,6 +24,8 @@ import order.serializers as serializers
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
|
|
||||||
class GeneralExtraLineList:
|
class GeneralExtraLineList:
|
||||||
"""
|
"""
|
||||||
@@ -347,6 +346,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating PurchaseOrder metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to receive stock items against a purchase order.
|
API endpoint to receive stock items against a purchase order.
|
||||||
@@ -916,6 +924,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||||
|
"""API endpoint for viewing / updating SalesOrder metadata"""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
||||||
|
|
||||||
|
queryset = models.SalesOrder.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to allocation stock items against a SalesOrder,
|
API endpoint to allocation stock items against a SalesOrder,
|
||||||
@@ -1138,10 +1155,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Individual purchase order detail URLs
|
# Individual purchase order detail URLs
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
|
||||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
|
||||||
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
||||||
|
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||||
|
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
|
||||||
|
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||||
|
|
||||||
|
# PurchaseOrder detail API endpoint
|
||||||
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@@ -1178,10 +1198,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Sales order detail view
|
# Sales order detail view
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
|
||||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
|
||||||
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||||
|
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||||
|
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||||
|
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
|
||||||
|
|
||||||
|
# SalesOrder detail endpoint
|
||||||
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django Forms for interacting with Order objects
|
Django Forms for interacting with Order objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-16 11:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0066_alter_purchaseorder_supplier'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='purchaseorder',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='salesorder',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-16 14:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0067_auto_20220516_1120'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='salesorderallocation',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -30,7 +30,9 @@ from users import models as UserModels
|
|||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
from plugin.models import MetadataMixin
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
@@ -97,7 +99,7 @@ def get_next_so_number():
|
|||||||
return reference
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Order(ReferenceIndexingMixin):
|
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||||
""" Abstract model for an order.
|
""" Abstract model for an order.
|
||||||
|
|
||||||
Instances of this class:
|
Instances of this class:
|
||||||
@@ -306,7 +308,7 @@ class PurchaseOrder(Order):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError({'quantity': _("Invalid quantity provided")})
|
raise ValidationError({'quantity': _("Invalid quantity provided")})
|
||||||
|
|
||||||
if not supplier_part.supplier == self.supplier:
|
if supplier_part.supplier != self.supplier:
|
||||||
raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
|
raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
|
||||||
|
|
||||||
if group:
|
if group:
|
||||||
@@ -445,7 +447,7 @@ class PurchaseOrder(Order):
|
|||||||
if barcode is None:
|
if barcode is None:
|
||||||
barcode = ''
|
barcode = ''
|
||||||
|
|
||||||
if not self.status == PurchaseOrderStatus.PLACED:
|
if self.status != PurchaseOrderStatus.PLACED:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Lines can only be received against an order marked as 'PLACED'"
|
"Lines can only be received against an order marked as 'PLACED'"
|
||||||
)
|
)
|
||||||
@@ -729,7 +731,7 @@ class SalesOrder(Order):
|
|||||||
Return True if this order can be cancelled
|
Return True if this order can be cancelled
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.status == SalesOrderStatus.PENDING:
|
if self.status != SalesOrderStatus.PENDING:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1267,12 +1269,6 @@ class SalesOrderAllocation(models.Model):
|
|||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-so-allocation-list')
|
return reverse('api-so-allocation-list')
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = [
|
|
||||||
# Cannot allocate any given StockItem to the same line more than once
|
|
||||||
('line', 'item'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Validate the SalesOrderAllocation object:
|
Validate the SalesOrderAllocation object:
|
||||||
@@ -1295,7 +1291,7 @@ class SalesOrderAllocation(models.Model):
|
|||||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.line.part == self.item.part:
|
if self.line.part != self.item.part:
|
||||||
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
errors['item'] = _('Cannot allocate stock item to a line with a different part')
|
||||||
except PartModels.Part.DoesNotExist:
|
except PartModels.Part.DoesNotExist:
|
||||||
errors['line'] = _('Cannot allocate stock to a line without a part')
|
errors['line'] = _('Cannot allocate stock to a line without a part')
|
||||||
@@ -1310,7 +1306,7 @@ class SalesOrderAllocation(models.Model):
|
|||||||
if self.quantity <= 0:
|
if self.quantity <= 0:
|
||||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||||
|
|
||||||
if self.item.serial and not self.quantity == 1:
|
if self.item.serial and self.quantity != 1:
|
||||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||||
|
|
||||||
if self.line.order != self.shipment.order:
|
if self.line.order != self.shipment.order:
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
JSON serializers for the Order API
|
JSON serializers for the Order API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -1287,14 +1284,18 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for entry in items:
|
for entry in items:
|
||||||
|
|
||||||
# Create a new SalesOrderAllocation
|
# Create a new SalesOrderAllocation
|
||||||
order.models.SalesOrderAllocation.objects.create(
|
allocation = order.models.SalesOrderAllocation(
|
||||||
line=entry.get('line_item'),
|
line=entry.get('line_item'),
|
||||||
item=entry.get('stock_item'),
|
item=entry.get('stock_item'),
|
||||||
quantity=entry.get('quantity'),
|
quantity=entry.get('quantity'),
|
||||||
shipment=shipment,
|
shipment=shipment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
allocation.full_clean()
|
||||||
|
allocation.save()
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||||
""" Serializer for a SalesOrderExtraLine object """
|
""" Serializer for a SalesOrderExtraLine object """
|
||||||
|
|||||||
@@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||||
|
|
||||||
|
def test_po_metadata(self):
|
||||||
|
url = reverse('api-po-metadata', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'yam': 'yum',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
|
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderReceiveTest(OrderTest):
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
@@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
||||||
|
|
||||||
|
def test_so_metadata(self):
|
||||||
|
url = reverse('api-so-metadata', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'metadata': {
|
||||||
|
'xyz': 'abc',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.SalesOrder.objects.get(pk=1)
|
||||||
|
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocateTest(OrderTest):
|
class SalesOrderAllocateTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
""" Unit tests for Order views (see views.py) """
|
""" Unit tests for Order views (see views.py) """
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
Django views for interacting with Order app
|
Django views for interacting with Order app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.http.response import JsonResponse
|
from django.http.response import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user