mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into not-working-tests
This commit is contained in:
commit
67733fa37b
76
InvenTree/InvenTree/exceptions.py
Normal file
76
InvenTree/InvenTree/exceptions.py
Normal 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
|
@ -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,28 +76,38 @@ 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
|
||||
try:
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
logger.warning(f"WARNING: '{taskname}' not started - Function not found")
|
||||
if is_worker_running() and not force_sync: # pragma: no cover
|
||||
# Running as asynchronous task
|
||||
try:
|
||||
task = AsyncTask(taskname, *args, **kwargs)
|
||||
task.run()
|
||||
except ImportError:
|
||||
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,17 +121,11 @@ 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")
|
||||
# Workers are not running: run it as synchronous task
|
||||
_func(*args, **kwargs)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
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
@ -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 @@
|
||||
![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg)
|
||||
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
|
||||
|
||||
![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree)
|
||||
[![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree)](https://coveralls.io/github/inventree/InvenTree)
|
||||
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
|
||||
![Lines of code](https://img.shields.io/tokei/lines/github/inventree/InvenTree)
|
||||
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/inventree/inventree)
|
||||
![PyPI - Downloads](https://img.shields.io/pypi/dm/inventree)
|
||||
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
|
||||
|
||||
![GitHub Org's stars](https://img.shields.io/github/stars/inventree?style=social)
|
||||
|
Loading…
Reference in New Issue
Block a user