Merge branch 'master' of https://github.com/inventree/InvenTree into devOps

This commit is contained in:
Matthias 2022-05-18 23:39:58 +02:00
commit 3fd5761089
No known key found for this signature in database
GPG Key ID: AB6D0E6C4CB65093
180 changed files with 35367 additions and 33646 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter

View File

@ -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

View File

@ -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)
) )

View File

@ -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"

View File

@ -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"))

View File

@ -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

View File

@ -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

View File

@ -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 = []

View File

@ -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,6 +76,11 @@ 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
@ -74,21 +88,26 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
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,18 +121,12 @@ 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,

View File

@ -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))

View File

@ -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')

View File

@ -5,8 +5,6 @@ In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap). 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'))

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,7 +0,0 @@
"""
Django Forms for interacting with Build objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -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')
}) })

View File

@ -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(

View File

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal from decimal import Decimal
import logging import logging

View File

@ -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):
""" """

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 _

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,)

View File

@ -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

View File

@ -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):

View File

@ -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 _

View File

@ -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)

View File

@ -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'),

View File

@ -1,5 +1,3 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -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

View File

@ -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,
},
),
]

View File

@ -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"),
}) })

View File

@ -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

View File

@ -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");
} }

View File

@ -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" %}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,8 +1,5 @@
# Tests for labels # Tests for labels
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
from django.conf import settings from django.conf import settings

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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'),
])), ])),

View File

@ -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 _

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0066_alter_purchaseorder_supplier'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='salesorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -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(),
),
]

View File

@ -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:

View File

@ -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 """

View File

@ -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):
""" """

View File

@ -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

View File

@ -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

View File

@ -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