mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-29 06:14:26 +00:00
Merge branch 'inventree:master' into not-working-tests
This commit is contained in:
@@ -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,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
Generic models which provide extra functionality over base Django model types.
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
import users.models
|
||||
|
||||
@@ -353,7 +353,7 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
|
||||
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
|
||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import json
|
||||
import warnings
|
||||
import requests
|
||||
import logging
|
||||
|
||||
@@ -11,6 +9,8 @@ from django.utils import timezone
|
||||
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.core import mail as django_mail
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@@ -52,6 +52,15 @@ def schedule_task(taskname, **kwargs):
|
||||
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):
|
||||
"""
|
||||
Create an AsyncTask if workers are running.
|
||||
@@ -67,6 +76,11 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
|
||||
import importlib
|
||||
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
|
||||
# Running as asynchronous task
|
||||
@@ -74,21 +88,26 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
task.run()
|
||||
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:
|
||||
# Split path
|
||||
try:
|
||||
app, mod, func = taskname.split('.')
|
||||
app_mod = app + '.' + mod
|
||||
except ValueError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
||||
raise_warning(f"WARNING: '{taskname}' not started - Malformed function path")
|
||||
return
|
||||
|
||||
# Import module from app
|
||||
try:
|
||||
_mod = importlib.import_module(app_mod)
|
||||
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
|
||||
|
||||
# Retrieve function
|
||||
@@ -102,18 +121,12 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
|
||||
if not _func:
|
||||
_func = eval(func) # pragma: no cover
|
||||
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
|
||||
|
||||
# Workers are not running: run it as synchronous task
|
||||
_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():
|
||||
"""
|
||||
@@ -205,25 +218,25 @@ def check_for_updates():
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||
|
||||
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)
|
||||
|
||||
tag = data.get('tag_name', None)
|
||||
|
||||
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)
|
||||
|
||||
if len(match.groups()) != 3:
|
||||
if len(match.groups()) != 3: # pragma: no cover
|
||||
logger.warning(f"Version '{tag}' did not match expected pattern")
|
||||
return
|
||||
|
||||
latest_version = [int(x) for x in match.groups()]
|
||||
|
||||
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}'")
|
||||
|
||||
@@ -288,7 +301,7 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
recipients = [recipients]
|
||||
|
||||
offload_task(
|
||||
'django.core.mail.send_mail',
|
||||
django_mail.send_mail,
|
||||
subject,
|
||||
body,
|
||||
from_email,
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
Unit tests for task management
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase
|
||||
from django_q.models import Schedule
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
threshold = timezone.now() - timedelta(days=30)
|
||||
threshold_low = threshold - timedelta(days=1)
|
||||
|
||||
|
||||
class ScheduledTaskTests(TestCase):
|
||||
@@ -41,3 +51,79 @@ class ScheduledTaskTests(TestCase):
|
||||
# But the 'minutes' should have been updated
|
||||
t = Schedule.objects.get(func=task)
|
||||
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))
|
||||
|
||||
@@ -795,13 +795,9 @@ class CurrencyRefreshView(RedirectView):
|
||||
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
|
||||
taskname = 'InvenTree.tasks.update_exchange_rates'
|
||||
|
||||
# Run it
|
||||
offload_task(taskname, force_sync=True)
|
||||
offload_task(update_exchange_rates, force_sync=True)
|
||||
|
||||
return redirect(reverse_lazy('settings'))
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -282,6 +282,13 @@ class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
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()
|
||||
|
||||
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Django Forms for interacting with Build objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
@@ -1139,12 +1139,13 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
"""
|
||||
Callback function to be executed after a Build instance is saved
|
||||
"""
|
||||
from . import tasks as build_tasks
|
||||
|
||||
if created:
|
||||
# A new Build has just been created
|
||||
|
||||
# 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):
|
||||
|
||||
@@ -199,7 +199,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity < 0:
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be greater than zero"))
|
||||
|
||||
part = self.get_part()
|
||||
@@ -209,7 +209,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
if part.trackable:
|
||||
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"))
|
||||
|
||||
return quantity
|
||||
@@ -232,7 +232,6 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
|
||||
serial_numbers = serial_numbers.strip()
|
||||
|
||||
# TODO: Field level validation necessary here?
|
||||
return serial_numbers
|
||||
|
||||
auto_allocate = serializers.BooleanField(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
|
||||
+209
-3
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -305,6 +302,215 @@ class BuildTest(BuildAPITest):
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
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 plugin.models import NotificationUserSetting
|
||||
from part.test_part import BaseNotificationIntegrationTest
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from common.models import NotificationEntry
|
||||
from . import tasks as common_tasks
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
|
||||
@@ -14,4 +15,4 @@ class TaskTest(TestCase):
|
||||
|
||||
# check empty run
|
||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||
offload_task('common.tasks.delete_old_notifications',)
|
||||
offload_task(common_tasks.delete_old_notifications,)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
@@ -24,6 +21,7 @@ from plugin.registry import registry
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
from part.models import Part
|
||||
from plugin.base.label import label as plugin_label
|
||||
|
||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
|
||||
@@ -156,7 +154,7 @@ class LabelPrintMixin:
|
||||
|
||||
# Offload a background task to print the provided label
|
||||
offload_task(
|
||||
'plugin.base.label.label.print_label',
|
||||
plugin_label.print_label,
|
||||
plugin.plugin_slug(),
|
||||
image,
|
||||
label_instance=label_instance,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
|
||||
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1177
-1162
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1306
-1291
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1203
-1188
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
File diff suppressed because it is too large
Load Diff
+1175
-1160
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 import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -1269,12 +1269,6 @@ class SalesOrderAllocation(models.Model):
|
||||
def get_api_url():
|
||||
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):
|
||||
"""
|
||||
Validate the SalesOrderAllocation object:
|
||||
|
||||
@@ -1284,14 +1284,18 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
|
||||
# Create a new SalesOrderAllocation
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
allocation = order.models.SalesOrderAllocation(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
shipment=shipment,
|
||||
)
|
||||
|
||||
allocation.full_clean()
|
||||
allocation.save()
|
||||
|
||||
|
||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||
""" Serializer for a SalesOrderExtraLine object """
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
@@ -59,7 +59,6 @@ from order import models as OrderModels
|
||||
from company.models import SupplierPart
|
||||
import part.settings as part_settings
|
||||
from stock import models as StockModels
|
||||
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
|
||||
@@ -2291,12 +2290,13 @@ def after_save_part(sender, instance: Part, created, **kwargs):
|
||||
"""
|
||||
Function to be executed after a Part is saved
|
||||
"""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if not created and not InvenTree.ready.isImportingData():
|
||||
# Check part stock only if we are *updating* the part (not creating it)
|
||||
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance)
|
||||
|
||||
|
||||
class PartAttachment(InvenTreeAttachment):
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -49,6 +46,6 @@ def notify_low_stock_if_required(part: part.models.Part):
|
||||
for p in parts:
|
||||
if p.is_part_low_on_stock():
|
||||
InvenTree.tasks.offload_task(
|
||||
'part.tasks.notify_low_stock',
|
||||
notify_low_stock,
|
||||
p
|
||||
)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.db import transaction
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Tests for the Part model
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def trigger_event(event, *args, **kwargs):
|
||||
logger.debug(f"Event triggered: '{event}'")
|
||||
|
||||
offload_task(
|
||||
'plugin.events.register_event',
|
||||
register_event,
|
||||
event,
|
||||
*args,
|
||||
**kwargs
|
||||
@@ -72,7 +72,7 @@ def register_event(event, *args, **kwargs):
|
||||
|
||||
# Offload a separate task for each plugin
|
||||
offload_task(
|
||||
'plugin.events.process_event',
|
||||
process_event,
|
||||
slug,
|
||||
event,
|
||||
*args,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""API for location plugins"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import ParseError, NotFound
|
||||
from rest_framework.response import Response
|
||||
@@ -56,7 +53,7 @@ class LocatePluginView(APIView):
|
||||
try:
|
||||
StockItem.objects.get(pk=item_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk)
|
||||
offload_task(registry.call_function, plugin, 'locate_stock_item', item_pk)
|
||||
|
||||
data['item'] = item_pk
|
||||
|
||||
@@ -69,7 +66,7 @@ class LocatePluginView(APIView):
|
||||
try:
|
||||
StockLocation.objects.get(pk=location_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk)
|
||||
offload_task(registry.call_function, plugin, 'locate_stock_location', location_pk)
|
||||
|
||||
data['location'] = location_pk
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from plugin.models import NotificationUserSetting
|
||||
from part.test_part import BaseNotificationIntegrationTest
|
||||
from plugin.builtin.integration.core_notifications import CoreNotificationsPlugin
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ReportSnippet, ReportAsset
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@@ -2020,10 +2020,11 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
|
||||
"""
|
||||
Function to be executed after a StockItem object is deleted
|
||||
"""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if not InvenTree.ready.isImportingData():
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
||||
|
||||
|
||||
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
|
||||
@@ -2031,10 +2032,11 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
"""
|
||||
Hook function to be executed after StockItem object is saved/updated
|
||||
"""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if not InvenTree.ready.isImportingData():
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
||||
|
||||
|
||||
class StockItemAttachment(InvenTreeAttachment):
|
||||
|
||||
@@ -225,6 +225,20 @@ function showApiError(xhr, url) {
|
||||
default:
|
||||
title = '{% trans "Unhandled Error Code" %}';
|
||||
message = `{% trans "Error code" %}: ${xhr.status}`;
|
||||
|
||||
var response = xhr.responseJSON;
|
||||
|
||||
// The server may have provided some extra information about this error
|
||||
if (response) {
|
||||
if (response.error) {
|
||||
title = response.error;
|
||||
}
|
||||
|
||||
if (response.detail) {
|
||||
message = response.detail;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.apps import apps
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||

|
||||

|
||||
|
||||

|
||||
[](https://coveralls.io/github/inventree/InvenTree)
|
||||
[](https://crowdin.com/project/inventree)
|
||||

|
||||

|
||||

|
||||
[](https://hub.docker.com/r/inventree/inventree)
|
||||
|
||||

|
||||
|
||||
Reference in New Issue
Block a user