mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
eb133ce185
@ -1,11 +1,14 @@
|
|||||||
"""Main JSON interface views."""
|
"""Main JSON interface views."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import filters, permissions
|
from rest_framework import filters, generics, permissions
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from .status import is_worker_running
|
from .status import is_worker_running
|
||||||
from .version import (inventreeApiVersion, inventreeInstanceName,
|
from .version import (inventreeApiVersion, inventreeInstanceName,
|
||||||
@ -50,6 +53,60 @@ class NotFoundView(AjaxView):
|
|||||||
return JsonResponse(data, status=404)
|
return JsonResponse(data, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteMixin:
|
||||||
|
"""Mixin class for enabling 'bulk delete' operations for various models.
|
||||||
|
|
||||||
|
Bulk delete allows for multiple items to be deleted in a single API query,
|
||||||
|
rather than using multiple API calls to the various detail endpoints.
|
||||||
|
|
||||||
|
This is implemented for two major reasons:
|
||||||
|
- Atomicity (guaranteed that either *all* items are deleted, or *none*)
|
||||||
|
- Speed (single API call and DB query)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""Perform a DELETE operation against this list endpoint.
|
||||||
|
|
||||||
|
We expect a list of primary-key (ID) values to be supplied as a JSON object, e.g.
|
||||||
|
{
|
||||||
|
items: [4, 8, 15, 16, 23, 42]
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
model = self.serializer_class.Meta.model
|
||||||
|
|
||||||
|
# Extract the items from the request body
|
||||||
|
try:
|
||||||
|
items = request.data.getlist('items', None)
|
||||||
|
except AttributeError:
|
||||||
|
items = request.data.get('items', None)
|
||||||
|
|
||||||
|
if items is None or type(items) is not list or not items:
|
||||||
|
raise ValidationError({
|
||||||
|
"non_field_errors": ["List of items must be provided for bulk deletion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Keep track of how many items we deleted
|
||||||
|
n_deleted = 0
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
objects = model.objects.filter(id__in=items)
|
||||||
|
n_deleted = objects.count()
|
||||||
|
objects.delete()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'success': f"Deleted {n_deleted} items",
|
||||||
|
},
|
||||||
|
status=204
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreateDestroyAPIView(BulkDeleteMixin, generics.ListCreateAPIView):
|
||||||
|
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class APIDownloadMixin:
|
class APIDownloadMixin:
|
||||||
"""Mixin for enabling a LIST endpoint to be downloaded a file.
|
"""Mixin for enabling a LIST endpoint to be downloaded a file.
|
||||||
|
|
||||||
|
@ -123,18 +123,30 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def post(self, url, data, expected_code=None, format='json'):
|
def post(self, url, data=None, expected_code=None, format='json'):
|
||||||
"""Issue a POST request."""
|
"""Issue a POST request."""
|
||||||
response = self.client.post(url, data=data, format=format)
|
response = self.client.post(url, data=data, format=format)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
|
if response.status_code != expected_code:
|
||||||
|
print(f"Unexpected response at '{url}':")
|
||||||
|
print(response.data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def delete(self, url, expected_code=None):
|
def delete(self, url, data=None, expected_code=None, format='json'):
|
||||||
"""Issue a DELETE request."""
|
"""Issue a DELETE request."""
|
||||||
response = self.client.delete(url)
|
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
response = self.client.delete(url, data=data, foramt=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
@ -155,6 +167,11 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
response = self.client.put(url, data=data, format=format)
|
response = self.client.put(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
|
if response.status_code != expected_code:
|
||||||
|
print(f"Unexpected response at '{url}':")
|
||||||
|
print(response.data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 57
|
INVENTREE_API_VERSION = 58
|
||||||
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
|
v58 -> 2022-06-06 : https://github.com/inventree/InvenTree/pull/3146
|
||||||
|
- Adds a BulkDelete API mixin class for fast, safe deletion of multiple objects with a single API request
|
||||||
|
|
||||||
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
|
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
|
||||||
- Transfer PartCategoryTemplateParameter actions to the API
|
- Transfer PartCategoryTemplateParameter actions to the API
|
||||||
|
|
||||||
|
@ -18,6 +18,8 @@ from PIL import Image
|
|||||||
|
|
||||||
import InvenTree.version
|
import InvenTree.version
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
from common.notifications import (InvenTreeNotificationBodies,
|
||||||
|
NotificationBody, trigger_notification)
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
|
|
||||||
from .api_tester import UserMixin
|
from .api_tester import UserMixin
|
||||||
@ -719,3 +721,46 @@ def inheritors(cls):
|
|||||||
class InvenTreeTestCase(UserMixin, TestCase):
|
class InvenTreeTestCase(UserMixin, TestCase):
|
||||||
"""Testcase with user setup buildin."""
|
"""Testcase with user setup buildin."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None):
|
||||||
|
"""Notify all responsible parties of a change in an instance.
|
||||||
|
|
||||||
|
Parses the supplied content with the provided instance and sender and sends a notification to all responsible users,
|
||||||
|
excluding the optional excluded list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: The newly created instance
|
||||||
|
sender: Sender model reference
|
||||||
|
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
|
||||||
|
exclude (User, optional): User instance that should be excluded. Defaults to None.
|
||||||
|
"""
|
||||||
|
if instance.responsible is not None:
|
||||||
|
# Setup context for notification parsing
|
||||||
|
content_context = {
|
||||||
|
'instance': str(instance),
|
||||||
|
'verbose_name': sender._meta.verbose_name,
|
||||||
|
'app_label': sender._meta.app_label,
|
||||||
|
'model_name': sender._meta.model_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup notification context
|
||||||
|
context = {
|
||||||
|
'instance': instance,
|
||||||
|
'name': content.name.format(**content_context),
|
||||||
|
'message': content.message.format(**content_context),
|
||||||
|
'link': InvenTree.helpers.construct_absolute_url(instance.get_absolute_url()),
|
||||||
|
'template': {
|
||||||
|
'html': content.template.format(**content_context),
|
||||||
|
'subject': content.name.format(**content_context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create notification
|
||||||
|
trigger_notification(
|
||||||
|
instance,
|
||||||
|
content.slug.format(**content_context),
|
||||||
|
targets=[instance.responsible],
|
||||||
|
target_exclude=[exclude],
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
@ -7,7 +7,7 @@ from rest_framework import filters, generics
|
|||||||
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
|
||||||
|
|
||||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin
|
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView
|
||||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
@ -413,7 +413,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
|
@ -24,7 +24,7 @@ from mptt.exceptions import InvalidMove
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode, notify_responsible
|
||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
|
|
||||||
@ -1049,6 +1049,9 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
|||||||
# 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)
|
||||||
|
|
||||||
|
# Notify the responsible users that the build order has been created
|
||||||
|
notify_responsible(instance, sender, exclude=instance.issued_by)
|
||||||
|
|
||||||
|
|
||||||
class BuildOrderAttachment(InvenTreeAttachment):
|
class BuildOrderAttachment(InvenTreeAttachment):
|
||||||
"""Model for storing file attachments against a BuildOrder object."""
|
"""Model for storing file attachments against a BuildOrder object."""
|
||||||
|
@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from InvenTree import status_codes as status
|
from InvenTree import status_codes as status
|
||||||
@ -14,6 +15,7 @@ import build.tasks
|
|||||||
from build.models import Build, BuildItem, get_next_build_number
|
from build.models import Build, BuildItem, get_next_build_number
|
||||||
from part.models import Part, BomItem, BomItemSubstitute
|
from part.models import Part, BomItem, BomItemSubstitute
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
|
|
||||||
class BuildTestBase(TestCase):
|
class BuildTestBase(TestCase):
|
||||||
@ -382,6 +384,46 @@ class BuildTest(BuildTestBase):
|
|||||||
for output in outputs:
|
for output in outputs:
|
||||||
self.assertFalse(output.is_building)
|
self.assertFalse(output.is_building)
|
||||||
|
|
||||||
|
def test_overdue_notification(self):
|
||||||
|
"""Test sending of notifications when a build order is overdue."""
|
||||||
|
|
||||||
|
self.build.target_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
self.build.save()
|
||||||
|
|
||||||
|
# Check for overdue orders
|
||||||
|
build.tasks.check_overdue_build_orders()
|
||||||
|
|
||||||
|
message = common.models.NotificationMessage.objects.get(
|
||||||
|
category='build.overdue_build_order',
|
||||||
|
user__id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message.name, 'Overdue Build Order')
|
||||||
|
|
||||||
|
def test_new_build_notification(self):
|
||||||
|
"""Test that a notification is sent when a new build is created"""
|
||||||
|
|
||||||
|
Build.objects.create(
|
||||||
|
reference='IIIII',
|
||||||
|
title='Some new build',
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=5,
|
||||||
|
issued_by=get_user_model().objects.get(pk=2),
|
||||||
|
responsible=Owner.create(obj=Group.objects.get(pk=3))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two notifications should have been sent
|
||||||
|
messages = common.models.NotificationMessage.objects.filter(
|
||||||
|
category='build.new_build',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(messages.count(), 2)
|
||||||
|
|
||||||
|
self.assertFalse(messages.filter(user__pk=2).exists())
|
||||||
|
|
||||||
|
self.assertTrue(messages.filter(user__pk=3).exists())
|
||||||
|
self.assertTrue(messages.filter(user__pk=4).exists())
|
||||||
|
|
||||||
|
|
||||||
class AutoAllocationTests(BuildTestBase):
|
class AutoAllocationTests(BuildTestBase):
|
||||||
"""Tests for auto allocating stock against a build order"""
|
"""Tests for auto allocating stock against a build order"""
|
||||||
@ -479,19 +521,3 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
|
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||||
|
|
||||||
def test_overdue_notification(self):
|
|
||||||
"""Test sending of notifications when a build order is overdue."""
|
|
||||||
|
|
||||||
self.build.target_date = datetime.now().date() - timedelta(days=1)
|
|
||||||
self.build.save()
|
|
||||||
|
|
||||||
# Check for overdue orders
|
|
||||||
build.tasks.check_overdue_build_orders()
|
|
||||||
|
|
||||||
message = common.models.NotificationMessage.objects.get(
|
|
||||||
category='build.overdue_build_order',
|
|
||||||
user__id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(message.name, 'Overdue Build Order')
|
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
"""Base classes and functions for notifications."""
|
"""Base classes and functions for notifications."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
from common.models import NotificationEntry, NotificationMessage
|
from common.models import NotificationEntry, NotificationMessage
|
||||||
from InvenTree.helpers import inheritors
|
|
||||||
from InvenTree.ready import isImportingData
|
from InvenTree.ready import isImportingData
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting
|
from plugin.models import NotificationUserSetting
|
||||||
@ -179,7 +181,7 @@ class MethodStorageClass:
|
|||||||
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
|
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
|
||||||
"""
|
"""
|
||||||
logger.info('collecting notification methods')
|
logger.info('collecting notification methods')
|
||||||
current_method = inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
||||||
|
|
||||||
# for testing selective loading is made available
|
# for testing selective loading is made available
|
||||||
if selected_classes:
|
if selected_classes:
|
||||||
@ -257,12 +259,51 @@ class UIMessageNotification(SingleNotificationMethod):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
|
class NotificationBody:
|
||||||
|
"""Information needed to create a notification.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Name (or subject) of the notification
|
||||||
|
slug (str): Slugified reference for notification
|
||||||
|
message (str): Notification message as text. Should not be longer than 120 chars.
|
||||||
|
template (str): Reference to the html template for the notification.
|
||||||
|
|
||||||
|
The strings support f-string sytle fomratting with context variables parsed at runtime.
|
||||||
|
|
||||||
|
Context variables:
|
||||||
|
instance: Text representing the instance
|
||||||
|
verbose_name: Verbose name of the model
|
||||||
|
app_label: App label (slugified) of the model
|
||||||
|
model_name': Name (slugified) of the model
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
message: str
|
||||||
|
template: str
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeNotificationBodies:
|
||||||
|
"""Default set of notifications for InvenTree.
|
||||||
|
|
||||||
|
Contains regularly used notification bodies.
|
||||||
|
"""
|
||||||
|
NewOrder = NotificationBody(
|
||||||
|
name=_("New {verbose_name}"),
|
||||||
|
slug='{app_label}.new_{model_name}',
|
||||||
|
message=_("A new {verbose_name} has been created and ,assigned to you"),
|
||||||
|
template='email/new_order_assigned.html',
|
||||||
|
)
|
||||||
|
"""Send when a new order (build, sale or purchase) was created."""
|
||||||
|
|
||||||
|
|
||||||
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||||
"""Send out a notification."""
|
"""Send out a notification."""
|
||||||
targets = kwargs.get('targets', None)
|
targets = kwargs.get('targets', None)
|
||||||
target_fnc = kwargs.get('target_fnc', None)
|
target_fnc = kwargs.get('target_fnc', None)
|
||||||
target_args = kwargs.get('target_args', [])
|
target_args = kwargs.get('target_args', [])
|
||||||
target_kwargs = kwargs.get('target_kwargs', {})
|
target_kwargs = kwargs.get('target_kwargs', {})
|
||||||
|
target_exclude = kwargs.get('target_exclude', None)
|
||||||
context = kwargs.get('context', {})
|
context = kwargs.get('context', {})
|
||||||
delivery_methods = kwargs.get('delivery_methods', None)
|
delivery_methods = kwargs.get('delivery_methods', None)
|
||||||
|
|
||||||
@ -290,6 +331,9 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
|
|
||||||
logger.info(f"Gathering users for notification '{category}'")
|
logger.info(f"Gathering users for notification '{category}'")
|
||||||
|
|
||||||
|
if target_exclude is None:
|
||||||
|
target_exclude = set()
|
||||||
|
|
||||||
# Collect possible targets
|
# Collect possible targets
|
||||||
if not targets:
|
if not targets:
|
||||||
targets = target_fnc(*target_args, **target_kwargs)
|
targets = target_fnc(*target_args, **target_kwargs)
|
||||||
@ -302,15 +346,19 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
for target in targets:
|
for target in targets:
|
||||||
# User instance is provided
|
# User instance is provided
|
||||||
if isinstance(target, get_user_model()):
|
if isinstance(target, get_user_model()):
|
||||||
target_users.add(target)
|
if target not in target_exclude:
|
||||||
|
target_users.add(target)
|
||||||
# Group instance is provided
|
# Group instance is provided
|
||||||
elif isinstance(target, Group):
|
elif isinstance(target, Group):
|
||||||
for user in get_user_model().objects.filter(groups__name=target.name):
|
for user in get_user_model().objects.filter(groups__name=target.name):
|
||||||
target_users.add(user)
|
if user not in target_exclude:
|
||||||
|
target_users.add(user)
|
||||||
# Owner instance (either 'user' or 'group' is provided)
|
# Owner instance (either 'user' or 'group' is provided)
|
||||||
elif isinstance(target, Owner):
|
elif isinstance(target, Owner):
|
||||||
for owner in target.get_related_owners(include_group=False):
|
for owner in target.get_related_owners(include_group=False):
|
||||||
target_users.add(owner.owner)
|
user = owner.owner
|
||||||
|
if user not in target_exclude:
|
||||||
|
target_users.add(user)
|
||||||
# Unhandled type
|
# Unhandled type
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown target passed to trigger_notification method: {target}")
|
logger.error(f"Unknown target passed to trigger_notification method: {target}")
|
||||||
|
@ -7,7 +7,7 @@ from django_filters import rest_framework as rest_filters
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import filters, generics
|
from rest_framework import filters, generics
|
||||||
|
|
||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
|
||||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||||
@ -98,7 +98,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
|
|||||||
active = rest_filters.BooleanFilter(field_name='part__active')
|
active = rest_filters.BooleanFilter(field_name='part__active')
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartList(generics.ListCreateAPIView):
|
class ManufacturerPartList(ListCreateDestroyAPIView):
|
||||||
"""API endpoint for list view of ManufacturerPart object.
|
"""API endpoint for list view of ManufacturerPart object.
|
||||||
|
|
||||||
- GET: Return list of ManufacturerPart objects
|
- GET: Return list of ManufacturerPart objects
|
||||||
@ -158,7 +158,7 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = ManufacturerPartSerializer
|
serializer_class = ManufacturerPartSerializer
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
|
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
|
||||||
|
|
||||||
queryset = ManufacturerPartAttachment.objects.all()
|
queryset = ManufacturerPartAttachment.objects.all()
|
||||||
@ -180,7 +180,7 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateD
|
|||||||
serializer_class = ManufacturerPartAttachmentSerializer
|
serializer_class = ManufacturerPartAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
class ManufacturerPartParameterList(ListCreateDestroyAPIView):
|
||||||
"""API endpoint for list view of ManufacturerPartParamater model."""
|
"""API endpoint for list view of ManufacturerPartParamater model."""
|
||||||
|
|
||||||
queryset = ManufacturerPartParameter.objects.all()
|
queryset = ManufacturerPartParameter.objects.all()
|
||||||
@ -253,7 +253,7 @@ class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = ManufacturerPartParameterSerializer
|
serializer_class = ManufacturerPartParameterSerializer
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartList(generics.ListCreateAPIView):
|
class SupplierPartList(ListCreateDestroyAPIView):
|
||||||
"""API endpoint for list view of SupplierPart object.
|
"""API endpoint for list view of SupplierPart object.
|
||||||
|
|
||||||
- GET: Return list of SupplierPart objects
|
- GET: Return list of SupplierPart objects
|
||||||
|
@ -10,7 +10,8 @@ from rest_framework.response import Response
|
|||||||
import order.models as models
|
import order.models as models
|
||||||
import order.serializers as serializers
|
import order.serializers as serializers
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from InvenTree.api import APIDownloadMixin, AttachmentMixin
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
|
ListCreateDestroyAPIView)
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import DownloadFile, str2bool
|
from InvenTree.helpers import DownloadFile, str2bool
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
@ -527,7 +528,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
||||||
|
|
||||||
queryset = models.SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
@ -1056,7 +1057,7 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
||||||
|
|
||||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
|
@ -30,7 +30,8 @@ import InvenTree.ready
|
|||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
from InvenTree.helpers import decimal2string, getSetting, increment
|
from InvenTree.helpers import (decimal2string, getSetting, increment,
|
||||||
|
notify_responsible)
|
||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||||
StockHistoryCode, StockStatus)
|
StockHistoryCode, StockStatus)
|
||||||
@ -574,6 +575,17 @@ class PurchaseOrder(Order):
|
|||||||
self.complete_order() # This will save the model
|
self.complete_order() # This will save the model
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=PurchaseOrder, dispatch_uid='purchase_order_post_save')
|
||||||
|
def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **kwargs):
|
||||||
|
"""Callback function to be executed after a PurchaseOrder is saved."""
|
||||||
|
if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData():
|
||||||
|
return
|
||||||
|
|
||||||
|
if created:
|
||||||
|
# Notify the responsible users that the purchase order has been created
|
||||||
|
notify_responsible(instance, sender, exclude=instance.created_by)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrder(Order):
|
class SalesOrder(Order):
|
||||||
"""A SalesOrder represents a list of goods shipped outwards to a customer.
|
"""A SalesOrder represents a list of goods shipped outwards to a customer.
|
||||||
|
|
||||||
@ -839,29 +851,29 @@ class SalesOrder(Order):
|
|||||||
return self.pending_shipments().count()
|
return self.pending_shipments().count()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
|
@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save')
|
||||||
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
|
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
|
||||||
"""Callback function to be executed after a SalesOrder instance is saved.
|
"""Callback function to be executed after a SalesOrder is saved.
|
||||||
|
|
||||||
- If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment
|
- If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment
|
||||||
- Ignore if the database is not ready for access
|
- Ignore if the database is not ready for access
|
||||||
- Ignore if data import is active
|
- Ignore if data import is active
|
||||||
"""
|
"""
|
||||||
|
if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData():
|
||||||
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if InvenTree.ready.isImportingData():
|
if created:
|
||||||
return
|
|
||||||
|
|
||||||
if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
|
|
||||||
# A new SalesOrder has just been created
|
# A new SalesOrder has just been created
|
||||||
|
|
||||||
# Create default shipment
|
if getSetting('SALESORDER_DEFAULT_SHIPMENT'):
|
||||||
SalesOrderShipment.objects.create(
|
# Create default shipment
|
||||||
order=instance,
|
SalesOrderShipment.objects.create(
|
||||||
reference='1',
|
order=instance,
|
||||||
)
|
reference='1',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify the responsible users that the sales order has been created
|
||||||
|
notify_responsible(instance, sender, exclude=instance.created_by)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachment(InvenTreeAttachment):
|
class PurchaseOrderAttachment(InvenTreeAttachment):
|
||||||
|
@ -260,3 +260,27 @@ class SalesOrderTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(len(messages), 2)
|
self.assertEqual(len(messages), 2)
|
||||||
|
|
||||||
|
def test_new_so_notification(self):
|
||||||
|
"""Test that a notification is sent when a new SalesOrder is created.
|
||||||
|
|
||||||
|
- The responsible user should receive a notification
|
||||||
|
- The creating user should *not* receive a notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
SalesOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
reference='1234567',
|
||||||
|
created_by=get_user_model().objects.get(pk=3),
|
||||||
|
responsible=Owner.create(obj=Group.objects.get(pk=3))
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = NotificationMessage.objects.filter(
|
||||||
|
category='order.new_salesorder',
|
||||||
|
)
|
||||||
|
|
||||||
|
# A notification should have been generated for user 4 (who is a member of group 3)
|
||||||
|
self.assertTrue(messages.filter(user__pk=4).exists())
|
||||||
|
|
||||||
|
# However *no* notification should have been generated for the creating user
|
||||||
|
self.assertFalse(messages.filter(user__pk=3).exists())
|
||||||
|
@ -9,7 +9,7 @@ from django.test import TestCase
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import order.tasks
|
import order.tasks
|
||||||
from company.models import SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
@ -237,3 +237,29 @@ class OrderTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(msg.target_object_id, 1)
|
self.assertEqual(msg.target_object_id, 1)
|
||||||
self.assertEqual(msg.name, 'Overdue Purchase Order')
|
self.assertEqual(msg.name, 'Overdue Purchase Order')
|
||||||
|
|
||||||
|
def test_new_po_notification(self):
|
||||||
|
"""Test that a notification is sent when a new PurchaseOrder is created
|
||||||
|
|
||||||
|
- The responsible user(s) should receive a notification
|
||||||
|
- The creating user should *not* receive a notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
PurchaseOrder.objects.create(
|
||||||
|
supplier=Company.objects.get(pk=1),
|
||||||
|
reference='XYZABC',
|
||||||
|
created_by=get_user_model().objects.get(pk=3),
|
||||||
|
responsible=Owner.create(obj=get_user_model().objects.get(pk=4)),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = common.models.NotificationMessage.objects.filter(
|
||||||
|
category='order.new_purchaseorder',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(messages.count(), 1)
|
||||||
|
|
||||||
|
# A notification should have been generated for user 4 (who is a member of group 3)
|
||||||
|
self.assertTrue(messages.filter(user__pk=4).exists())
|
||||||
|
|
||||||
|
# However *no* notification should have been generated for the creating user
|
||||||
|
self.assertFalse(messages.filter(user__pk=3).exists())
|
||||||
|
@ -22,7 +22,8 @@ import order.models
|
|||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import Company, ManufacturerPart, SupplierPart
|
from company.models import Company, ManufacturerPart, SupplierPart
|
||||||
from InvenTree.api import APIDownloadMixin, AttachmentMixin
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
|
ListCreateDestroyAPIView)
|
||||||
from InvenTree.helpers import DownloadFile, increment, isNull, str2bool
|
from InvenTree.helpers import DownloadFile, increment, isNull, str2bool
|
||||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||||
SalesOrderStatus)
|
SalesOrderStatus)
|
||||||
@ -302,7 +303,7 @@ class PartInternalPriceList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
|
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
|
||||||
|
|
||||||
queryset = PartAttachment.objects.all()
|
queryset = PartAttachment.objects.all()
|
||||||
@ -1522,7 +1523,7 @@ class BomFilter(rest_filters.FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class BomList(generics.ListCreateAPIView):
|
class BomList(ListCreateDestroyAPIView):
|
||||||
"""API endpoint for accessing a list of BomItem objects.
|
"""API endpoint for accessing a list of BomItem objects.
|
||||||
|
|
||||||
- GET: Return list of BomItem objects
|
- GET: Return list of BomItem objects
|
||||||
|
@ -22,7 +22,8 @@ import stock.serializers as StockSerializers
|
|||||||
from build.models import Build
|
from build.models import Build
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from company.serializers import CompanySerializer, SupplierPartSerializer
|
from company.serializers import CompanySerializer, SupplierPartSerializer
|
||||||
from InvenTree.api import APIDownloadMixin, AttachmentMixin
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
|
ListCreateDestroyAPIView)
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
|
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
|
||||||
str2bool)
|
str2bool)
|
||||||
@ -465,7 +466,7 @@ class StockFilter(rest_filters.FilterSet):
|
|||||||
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
|
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
|
||||||
|
|
||||||
|
|
||||||
class StockList(APIDownloadMixin, generics.ListCreateAPIView):
|
class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for list view of Stock objects.
|
"""API endpoint for list view of Stock objects.
|
||||||
|
|
||||||
- GET: Return a list of all StockItem objects (with optional query filters)
|
- GET: Return a list of all StockItem objects (with optional query filters)
|
||||||
@ -1043,7 +1044,7 @@ class StockList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a StockItemAttachment (file upload)."""
|
"""API endpoint for listing (and creating) a StockItemAttachment (file upload)."""
|
||||||
|
|
||||||
queryset = StockItemAttachment.objects.all()
|
queryset = StockItemAttachment.objects.all()
|
||||||
@ -1074,7 +1075,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestResultList(generics.ListCreateAPIView):
|
class StockItemTestResultList(ListCreateDestroyAPIView):
|
||||||
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
||||||
|
|
||||||
queryset = StockItemTestResult.objects.all()
|
queryset = StockItemTestResult.objects.all()
|
||||||
|
@ -276,12 +276,12 @@
|
|||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
|
|
||||||
var results = [];
|
var items = [];
|
||||||
|
|
||||||
// Ensure that we are only deleting the correct test results
|
// Ensure that we are only deleting the correct test results
|
||||||
response.forEach(function(item) {
|
response.forEach(function(result) {
|
||||||
if (item.stock_item == {{ item.pk }}) {
|
if (result.stock_item == {{ item.pk }}) {
|
||||||
results.push(item);
|
items.push(result.pk);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -290,22 +290,14 @@
|
|||||||
{% trans "Delete all test results for this stock item" %}
|
{% trans "Delete all test results for this stock item" %}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm(url, {
|
||||||
|
form_data: {
|
||||||
|
items: items,
|
||||||
|
},
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete Test Data" %}',
|
title: '{% trans "Delete Test Data" %}',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSubmit: function(fields, opts) {
|
onSuccess: reloadTable,
|
||||||
inventreeMultiDelete(
|
|
||||||
url,
|
|
||||||
results,
|
|
||||||
{
|
|
||||||
modal: opts.modal,
|
|
||||||
success: function() {
|
|
||||||
reloadTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ import part.models
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||||
|
|
||||||
|
|
||||||
class StockAPITestCase(InvenTreeAPITestCase):
|
class StockAPITestCase(InvenTreeAPITestCase):
|
||||||
@ -934,6 +934,50 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
# Check that an attachment has been uploaded
|
# Check that an attachment has been uploaded
|
||||||
self.assertIsNotNone(response.data['attachment'])
|
self.assertIsNotNone(response.data['attachment'])
|
||||||
|
|
||||||
|
def test_bulk_delete(self):
|
||||||
|
"""Test that the BulkDelete endpoint works for this model"""
|
||||||
|
|
||||||
|
n = StockItemTestResult.objects.count()
|
||||||
|
|
||||||
|
tests = []
|
||||||
|
|
||||||
|
url = reverse('api-stock-test-result-list')
|
||||||
|
|
||||||
|
# Create some objects (via the API)
|
||||||
|
for _ii in range(50):
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'stock_item': 1,
|
||||||
|
'test': f"Some test {_ii}",
|
||||||
|
'result': True,
|
||||||
|
'value': 'Test result value'
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
tests.append(response.data['pk'])
|
||||||
|
|
||||||
|
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
|
||||||
|
|
||||||
|
# Attempt a delete without providing items
|
||||||
|
self.delete(
|
||||||
|
url,
|
||||||
|
{},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now, let's delete all the newly created items with a single API request
|
||||||
|
response = self.delete(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'items': tests,
|
||||||
|
},
|
||||||
|
expected_code=204
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(StockItemTestResult.objects.count(), n)
|
||||||
|
|
||||||
|
|
||||||
class StockAssignTest(StockAPITestCase):
|
class StockAssignTest(StockAPITestCase):
|
||||||
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
||||||
|
11
InvenTree/templates/email/new_order_assigned.html
Normal file
11
InvenTree/templates/email/new_order_assigned.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% extends "email/email.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{ message }}
|
||||||
|
{% if link %}
|
||||||
|
<p>{% trans "Click on the following link to view this order" %}: <a href="{{ link }}">{{ link }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock title %}
|
@ -7,7 +7,6 @@
|
|||||||
/* exported
|
/* exported
|
||||||
inventreeGet,
|
inventreeGet,
|
||||||
inventreeDelete,
|
inventreeDelete,
|
||||||
inventreeMultiDelete,
|
|
||||||
inventreeFormDataUpload,
|
inventreeFormDataUpload,
|
||||||
showApiError,
|
showApiError,
|
||||||
*/
|
*/
|
||||||
@ -160,59 +159,20 @@ function inventreePut(url, data={}, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Performs a DELETE API call to the server
|
||||||
|
*/
|
||||||
function inventreeDelete(url, options={}) {
|
function inventreeDelete(url, options={}) {
|
||||||
/*
|
|
||||||
* Delete a record
|
|
||||||
*/
|
|
||||||
|
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
options.method = 'DELETE';
|
options.method = 'DELETE';
|
||||||
|
|
||||||
return inventreePut(url, {}, options);
|
return inventreePut(
|
||||||
}
|
url,
|
||||||
|
options.data || {},
|
||||||
|
options
|
||||||
/*
|
);
|
||||||
* Perform a 'multi delete' operation:
|
|
||||||
*
|
|
||||||
* - Items are deleted sequentially from the database, rather than simultaneous requests
|
|
||||||
* - This prevents potential overload / transaction issues in the DB backend
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - Assumes that each item in the 'items' list has a parameter 'pk'
|
|
||||||
*/
|
|
||||||
function inventreeMultiDelete(url, items, options={}) {
|
|
||||||
|
|
||||||
if (!url.endsWith('/')) {
|
|
||||||
url += '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
function doNextDelete() {
|
|
||||||
if (items.length > 0) {
|
|
||||||
var item = items.shift();
|
|
||||||
|
|
||||||
inventreeDelete(`${url}${item.pk}/`, {
|
|
||||||
complete: doNextDelete
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (options.modal) {
|
|
||||||
$(options.modal).modal('hide');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.success) {
|
|
||||||
options.success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.modal) {
|
|
||||||
showModalSpinner(options.modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate the process
|
|
||||||
doNextDelete();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,9 +86,11 @@ function deleteAttachments(attachments, url, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
attachments.forEach(function(att) {
|
attachments.forEach(function(att) {
|
||||||
rows += renderAttachment(att);
|
rows += renderAttachment(att);
|
||||||
|
ids.push(att.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
@ -105,22 +107,16 @@ function deleteAttachments(attachments, url, options={}) {
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete Attachments" %}',
|
title: '{% trans "Delete Attachments" %}',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSubmit: function(fields, opts) {
|
form_data: {
|
||||||
inventreeMultiDelete(
|
items: ids,
|
||||||
url,
|
},
|
||||||
attachments,
|
onSuccess: function() {
|
||||||
{
|
// Refresh the table once all attachments are deleted
|
||||||
modal: opts.modal,
|
$('#attachment-table').bootstrapTable('refresh');
|
||||||
success: function() {
|
|
||||||
// Refresh the table once all attachments are deleted
|
|
||||||
$('#attachment-table').bootstrapTable('refresh');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -672,9 +672,11 @@ function deleteBomItems(items, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
items.forEach(function(item) {
|
items.forEach(function(item) {
|
||||||
rows += renderItem(item);
|
rows += renderItem(item);
|
||||||
|
ids.push(item.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
@ -692,22 +694,14 @@ function deleteBomItems(items, options={}) {
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm('{% url "api-bom-list" %}', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete selected BOM items?" %}',
|
title: '{% trans "Delete selected BOM items?" %}',
|
||||||
fields: {},
|
form_data: {
|
||||||
preFormContent: html,
|
items: ids,
|
||||||
onSubmit: function(fields, opts) {
|
|
||||||
|
|
||||||
inventreeMultiDelete(
|
|
||||||
'{% url "api-bom-list" %}',
|
|
||||||
items,
|
|
||||||
{
|
|
||||||
modal: opts.modal,
|
|
||||||
success: options.success,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
preFormContent: html,
|
||||||
|
onSuccess: options.success,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
/* globals
|
/* globals
|
||||||
constructForm,
|
constructForm,
|
||||||
imageHoverIcon,
|
imageHoverIcon,
|
||||||
inventreeMultiDelete,
|
|
||||||
loadTableFilters,
|
loadTableFilters,
|
||||||
makeIconButton,
|
makeIconButton,
|
||||||
renderLink,
|
renderLink,
|
||||||
@ -238,9 +237,11 @@ function deleteSupplierParts(parts, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
parts.forEach(function(sup_part) {
|
parts.forEach(function(sup_part) {
|
||||||
rows += renderPart(sup_part);
|
rows += renderPart(sup_part);
|
||||||
|
ids.push(sup_part.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
@ -258,21 +259,14 @@ function deleteSupplierParts(parts, options={}) {
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm('{% url "api-supplier-part-list" %}', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete Supplier Parts" %}',
|
title: '{% trans "Delete Supplier Parts" %}',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSubmit: function(fields, opts) {
|
form_data: {
|
||||||
|
items: ids,
|
||||||
inventreeMultiDelete(
|
},
|
||||||
'{% url "api-supplier-part-list" %}',
|
onSuccess: options.success,
|
||||||
parts,
|
|
||||||
{
|
|
||||||
modal: opts.modal,
|
|
||||||
success: options.success
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,9 +466,11 @@ function deleteManufacturerParts(selections, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
selections.forEach(function(man_part) {
|
selections.forEach(function(man_part) {
|
||||||
rows += renderPart(man_part);
|
rows += renderPart(man_part);
|
||||||
|
ids.push(man_part.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
@ -491,21 +487,14 @@ function deleteManufacturerParts(selections, options={}) {
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm('{% url "api-manufacturer-part-list" %}', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete Manufacturer Parts" %}',
|
title: '{% trans "Delete Manufacturer Parts" %}',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSubmit: function(fields, opts) {
|
form_data: {
|
||||||
|
items: ids,
|
||||||
inventreeMultiDelete(
|
},
|
||||||
'{% url "api-manufacturer-part-list" %}',
|
onSuccess: options.success,
|
||||||
selections,
|
|
||||||
{
|
|
||||||
modal: opts.modal,
|
|
||||||
success: options.success,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -525,9 +514,11 @@ function deleteManufacturerPartParameters(selections, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
selections.forEach(function(param) {
|
selections.forEach(function(param) {
|
||||||
rows += renderParam(param);
|
rows += renderParam(param);
|
||||||
|
ids.push(param.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
@ -543,20 +534,14 @@ function deleteManufacturerPartParameters(selections, options={}) {
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructFormBody({}, {
|
constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
title: '{% trans "Delete Parameters" %}',
|
title: '{% trans "Delete Parameters" %}',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
onSubmit: function(fields, opts) {
|
form_data: {
|
||||||
inventreeMultiDelete(
|
items: ids,
|
||||||
'{% url "api-manufacturer-part-parameter-list" %}',
|
},
|
||||||
selections,
|
onSuccess: options.success,
|
||||||
{
|
|
||||||
modal: opts.modal,
|
|
||||||
success: options.success,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -725,7 +725,8 @@ function submitFormData(fields, options) {
|
|||||||
// Only used if file / image upload is required
|
// Only used if file / image upload is required
|
||||||
var form_data = new FormData();
|
var form_data = new FormData();
|
||||||
|
|
||||||
var data = {};
|
// We can (optionally) provide a "starting point" for the submitted data
|
||||||
|
var data = options.form_data || {};
|
||||||
|
|
||||||
var has_files = false;
|
var has_files = false;
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@
|
|||||||
handleFormErrors,
|
handleFormErrors,
|
||||||
imageHoverIcon,
|
imageHoverIcon,
|
||||||
inventreeGet,
|
inventreeGet,
|
||||||
inventreeMultiDelete,
|
|
||||||
inventreePut,
|
inventreePut,
|
||||||
launchModalForm,
|
launchModalForm,
|
||||||
linkButtonsToSelection,
|
linkButtonsToSelection,
|
||||||
@ -1107,12 +1106,23 @@ function adjustStock(action, items, options={}) {
|
|||||||
// Delete action is handled differently
|
// Delete action is handled differently
|
||||||
if (action == 'delete') {
|
if (action == 'delete') {
|
||||||
|
|
||||||
inventreeMultiDelete(
|
var ids = [];
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
ids.push(item.pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
showModalSpinner(opts.modal, true);
|
||||||
|
inventreeDelete(
|
||||||
'{% url "api-stock-list" %}',
|
'{% url "api-stock-list" %}',
|
||||||
items,
|
|
||||||
{
|
{
|
||||||
modal: opts.modal,
|
data: {
|
||||||
success: options.success,
|
items: ids,
|
||||||
|
},
|
||||||
|
success: function(response) {
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
options.success(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user