diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 974d518e6a..a9f55b23a6 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -1,11 +1,14 @@ """Main JSON interface views.""" from django.conf import settings +from django.db import transaction from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ 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 .version import (inventreeApiVersion, inventreeInstanceName, @@ -50,6 +53,60 @@ class NotFoundView(AjaxView): 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: """Mixin for enabling a LIST endpoint to be downloaded a file. diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 08952a7df1..ef1007c66b 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -123,18 +123,30 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): 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.""" response = self.client.post(url, data=data, format=format) + if data is None: + data = {} + 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) return response - def delete(self, url, expected_code=None): + def delete(self, url, data=None, expected_code=None, format='json'): """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: self.assertEqual(response.status_code, expected_code) @@ -155,6 +167,11 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): response = self.client.put(url, data=data, format=format) 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) return response diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 1ba4f259fb..7bb41bd8c5 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # 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 +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 - Transfer PartCategoryTemplateParameter actions to the API diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 69369c9f3b..34f296c2f8 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -18,6 +18,8 @@ from PIL import Image import InvenTree.version from common.models import InvenTreeSetting +from common.notifications import (InvenTreeNotificationBodies, + NotificationBody, trigger_notification) from common.settings import currency_code_default from .api_tester import UserMixin @@ -719,3 +721,46 @@ def inheritors(cls): class InvenTreeTestCase(UserMixin, TestCase): """Testcase with user setup buildin.""" 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, + ) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index cd6c6ba82a..89e929256c 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -7,7 +7,7 @@ from rest_framework import filters, generics from django_filters.rest_framework import DjangoFilterBackend 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.filters import InvenTreeOrderingFilter 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.""" queryset = BuildOrderAttachment.objects.all() diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index cbcfc72c87..3659ad4b52 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -24,7 +24,7 @@ from mptt.exceptions import InvalidMove from rest_framework import serializers 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.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 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): """Model for storing file attachments against a BuildOrder object.""" diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index cd411df6b1..663a247adf 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from django.test import TestCase from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.exceptions import ValidationError 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 part.models import Part, BomItem, BomItemSubstitute from stock.models import StockItem +from users.models import Owner class BuildTestBase(TestCase): @@ -382,6 +384,46 @@ class BuildTest(BuildTestBase): for output in outputs: 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): """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_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') diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 13d0450043..6f8a47ca5a 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -1,13 +1,15 @@ """Base classes and functions for notifications.""" import logging +from dataclasses import dataclass from datetime import timedelta from django.contrib.auth import get_user_model 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 InvenTree.helpers import inheritors from InvenTree.ready import isImportingData from plugin import registry 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. """ 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 if selected_classes: @@ -257,12 +259,51 @@ class UIMessageNotification(SingleNotificationMethod): 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): """Send out a notification.""" targets = kwargs.get('targets', None) target_fnc = kwargs.get('target_fnc', None) target_args = kwargs.get('target_args', []) target_kwargs = kwargs.get('target_kwargs', {}) + target_exclude = kwargs.get('target_exclude', None) context = kwargs.get('context', {}) 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}'") + if target_exclude is None: + target_exclude = set() + # Collect possible targets if not targets: 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: # User instance is provided if isinstance(target, get_user_model()): - target_users.add(target) + if target not in target_exclude: + target_users.add(target) # Group instance is provided elif isinstance(target, Group): 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) elif isinstance(target, Owner): 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 else: logger.error(f"Unknown target passed to trigger_notification method: {target}") diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index c0455abcca..2e8544002b 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -7,7 +7,7 @@ from django_filters import rest_framework as rest_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, generics -from InvenTree.api import AttachmentMixin +from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView from InvenTree.helpers import str2bool from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, @@ -98,7 +98,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet): active = rest_filters.BooleanFilter(field_name='part__active') -class ManufacturerPartList(generics.ListCreateAPIView): +class ManufacturerPartList(ListCreateDestroyAPIView): """API endpoint for list view of ManufacturerPart object. - GET: Return list of ManufacturerPart objects @@ -158,7 +158,7 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = ManufacturerPartSerializer -class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView): +class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): """API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).""" queryset = ManufacturerPartAttachment.objects.all() @@ -180,7 +180,7 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateD serializer_class = ManufacturerPartAttachmentSerializer -class ManufacturerPartParameterList(generics.ListCreateAPIView): +class ManufacturerPartParameterList(ListCreateDestroyAPIView): """API endpoint for list view of ManufacturerPartParamater model.""" queryset = ManufacturerPartParameter.objects.all() @@ -253,7 +253,7 @@ class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = ManufacturerPartParameterSerializer -class SupplierPartList(generics.ListCreateAPIView): +class SupplierPartList(ListCreateDestroyAPIView): """API endpoint for list view of SupplierPart object. - GET: Return list of SupplierPart objects diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 9ad123577d..0826b9fdb0 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -10,7 +10,8 @@ from rest_framework.response import Response import order.models as models import order.serializers as serializers 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.helpers import DownloadFile, str2bool from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus @@ -527,7 +528,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = serializers.PurchaseOrderExtraLineSerializer -class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView): +class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): """API endpoint for listing (and creating) a SalesOrderAttachment (file upload)""" queryset = models.SalesOrderAttachment.objects.all() @@ -1056,7 +1057,7 @@ class SalesOrderShipmentComplete(generics.CreateAPIView): return ctx -class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView): +class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): """API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)""" queryset = models.PurchaseOrderAttachment.objects.all() diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index bba4e91909..be96a8fa2b 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -30,7 +30,8 @@ import InvenTree.ready from common.settings import currency_code_default from company.models import Company, SupplierPart 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.status_codes import (PurchaseOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) @@ -574,6 +575,17 @@ class PurchaseOrder(Order): 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): """A SalesOrder represents a list of goods shipped outwards to a customer. @@ -839,29 +851,29 @@ class SalesOrder(Order): 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): - """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 - Ignore if the database is not ready for access - Ignore if data import is active """ - - if not InvenTree.ready.canAppAccessDatabase(allow_test=True): + if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData(): return - if InvenTree.ready.isImportingData(): - return - - if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'): + if created: # A new SalesOrder has just been created - # Create default shipment - SalesOrderShipment.objects.create( - order=instance, - reference='1', - ) + if getSetting('SALESORDER_DEFAULT_SHIPMENT'): + # Create default shipment + SalesOrderShipment.objects.create( + 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): diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index b0d570cef2..21303a2c2c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -260,3 +260,27 @@ class SalesOrderTest(TestCase): ) 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()) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 9b6077a14e..a2042f08ed 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -9,7 +9,7 @@ from django.test import TestCase import common.models import order.tasks -from company.models import SupplierPart +from company.models import Company, SupplierPart from InvenTree.status_codes import PurchaseOrderStatus from part.models import Part from stock.models import StockLocation @@ -237,3 +237,29 @@ class OrderTest(TestCase): self.assertEqual(msg.target_object_id, 1) 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()) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 31ae1adca5..23b2955744 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -22,7 +22,8 @@ import order.models from build.models import Build, BuildItem from common.models import InvenTreeSetting 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.status_codes import (BuildStatus, PurchaseOrderStatus, 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).""" queryset = PartAttachment.objects.all() @@ -1522,7 +1523,7 @@ class BomFilter(rest_filters.FilterSet): return queryset -class BomList(generics.ListCreateAPIView): +class BomList(ListCreateDestroyAPIView): """API endpoint for accessing a list of BomItem objects. - GET: Return list of BomItem objects diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index d22c0c37dc..13b3c0219e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -22,7 +22,8 @@ import stock.serializers as StockSerializers from build.models import Build from company.models import Company, SupplierPart 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.helpers import (DownloadFile, extract_serial_numbers, isNull, str2bool) @@ -465,7 +466,7 @@ class StockFilter(rest_filters.FilterSet): 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. - 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).""" queryset = StockItemAttachment.objects.all() @@ -1074,7 +1075,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = StockSerializers.StockItemTestResultSerializer -class StockItemTestResultList(generics.ListCreateAPIView): +class StockItemTestResultList(ListCreateDestroyAPIView): """API endpoint for listing (and creating) a StockItemTestResult object.""" queryset = StockItemTestResult.objects.all() diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index e951428378..c7c6019ddb 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -276,12 +276,12 @@ { success: function(response) { - var results = []; + var items = []; // Ensure that we are only deleting the correct test results - response.forEach(function(item) { - if (item.stock_item == {{ item.pk }}) { - results.push(item); + response.forEach(function(result) { + if (result.stock_item == {{ item.pk }}) { + items.push(result.pk); } }); @@ -290,22 +290,14 @@ {% trans "Delete all test results for this stock item" %} `; - constructFormBody({}, { + constructForm(url, { + form_data: { + items: items, + }, method: 'DELETE', title: '{% trans "Delete Test Data" %}', preFormContent: html, - onSubmit: function(fields, opts) { - inventreeMultiDelete( - url, - results, - { - modal: opts.modal, - success: function() { - reloadTable(); - } - } - ) - } + onSuccess: reloadTable, }); } } diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 4de9d47031..b0213e5a0b 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -15,7 +15,7 @@ import part.models from common.models import InvenTreeSetting from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import StockStatus -from stock.models import StockItem, StockLocation +from stock.models import StockItem, StockItemTestResult, StockLocation class StockAPITestCase(InvenTreeAPITestCase): @@ -934,6 +934,50 @@ class StockTestResultTest(StockAPITestCase): # Check that an attachment has been uploaded 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): """Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer.""" diff --git a/InvenTree/templates/email/new_order_assigned.html b/InvenTree/templates/email/new_order_assigned.html new file mode 100644 index 0000000000..9d4161d352 --- /dev/null +++ b/InvenTree/templates/email/new_order_assigned.html @@ -0,0 +1,11 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{{ message }} +{% if link %} +
{% trans "Click on the following link to view this order" %}: {{ link }}
+{% endif %} +{% endblock title %} diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index eda93984f6..0bb8b2b2ea 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -7,7 +7,6 @@ /* exported inventreeGet, inventreeDelete, - inventreeMultiDelete, inventreeFormDataUpload, showApiError, */ @@ -160,59 +159,20 @@ function inventreePut(url, data={}, options={}) { } +/* + * Performs a DELETE API call to the server + */ function inventreeDelete(url, options={}) { - /* - * Delete a record - */ options = options || {}; options.method = 'DELETE'; - return inventreePut(url, {}, 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(); - + return inventreePut( + url, + options.data || {}, + options + ); } diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 7d89725a65..fcf89fe7e2 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -86,9 +86,11 @@ function deleteAttachments(attachments, url, options={}) { } var rows = ''; + var ids = []; attachments.forEach(function(att) { rows += renderAttachment(att); + ids.push(att.pk); }); var html = ` @@ -105,22 +107,16 @@ function deleteAttachments(attachments, url, options={}) { `; - constructFormBody({}, { + constructForm(url, { method: 'DELETE', title: '{% trans "Delete Attachments" %}', preFormContent: html, - onSubmit: function(fields, opts) { - inventreeMultiDelete( - url, - attachments, - { - modal: opts.modal, - success: function() { - // Refresh the table once all attachments are deleted - $('#attachment-table').bootstrapTable('refresh'); - } - } - ); + form_data: { + items: ids, + }, + onSuccess: function() { + // Refresh the table once all attachments are deleted + $('#attachment-table').bootstrapTable('refresh'); } }); } diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index c62e29bdf5..b0f0999f35 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -672,9 +672,11 @@ function deleteBomItems(items, options={}) { } var rows = ''; + var ids = []; items.forEach(function(item) { rows += renderItem(item); + ids.push(item.pk); }); var html = ` @@ -692,22 +694,14 @@ function deleteBomItems(items, options={}) { `; - constructFormBody({}, { + constructForm('{% url "api-bom-list" %}', { method: 'DELETE', title: '{% trans "Delete selected BOM items?" %}', - fields: {}, - preFormContent: html, - onSubmit: function(fields, opts) { - - inventreeMultiDelete( - '{% url "api-bom-list" %}', - items, - { - modal: opts.modal, - success: options.success, - } - ); + form_data: { + items: ids, }, + preFormContent: html, + onSuccess: options.success, }); } diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index e560d6a72f..7254fe461b 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -3,7 +3,6 @@ /* globals constructForm, imageHoverIcon, - inventreeMultiDelete, loadTableFilters, makeIconButton, renderLink, @@ -238,9 +237,11 @@ function deleteSupplierParts(parts, options={}) { } var rows = ''; + var ids = []; parts.forEach(function(sup_part) { rows += renderPart(sup_part); + ids.push(sup_part.pk); }); var html = ` @@ -258,21 +259,14 @@ function deleteSupplierParts(parts, options={}) { `; - constructFormBody({}, { + constructForm('{% url "api-supplier-part-list" %}', { method: 'DELETE', title: '{% trans "Delete Supplier Parts" %}', preFormContent: html, - onSubmit: function(fields, opts) { - - inventreeMultiDelete( - '{% url "api-supplier-part-list" %}', - parts, - { - modal: opts.modal, - success: options.success - } - ); - } + form_data: { + items: ids, + }, + onSuccess: options.success, }); } @@ -472,9 +466,11 @@ function deleteManufacturerParts(selections, options={}) { } var rows = ''; + var ids = []; selections.forEach(function(man_part) { rows += renderPart(man_part); + ids.push(man_part.pk); }); var html = ` @@ -491,21 +487,14 @@ function deleteManufacturerParts(selections, options={}) { `; - constructFormBody({}, { + constructForm('{% url "api-manufacturer-part-list" %}', { method: 'DELETE', title: '{% trans "Delete Manufacturer Parts" %}', preFormContent: html, - onSubmit: function(fields, opts) { - - inventreeMultiDelete( - '{% url "api-manufacturer-part-list" %}', - selections, - { - modal: opts.modal, - success: options.success, - } - ); - } + form_data: { + items: ids, + }, + onSuccess: options.success, }); } @@ -525,9 +514,11 @@ function deleteManufacturerPartParameters(selections, options={}) { } var rows = ''; + var ids = []; selections.forEach(function(param) { rows += renderParam(param); + ids.push(param.pk); }); var html = ` @@ -543,20 +534,14 @@ function deleteManufacturerPartParameters(selections, options={}) { `; - constructFormBody({}, { + constructForm('{% url "api-manufacturer-part-parameter-list" %}', { method: 'DELETE', title: '{% trans "Delete Parameters" %}', preFormContent: html, - onSubmit: function(fields, opts) { - inventreeMultiDelete( - '{% url "api-manufacturer-part-parameter-list" %}', - selections, - { - modal: opts.modal, - success: options.success, - } - ); - } + form_data: { + items: ids, + }, + onSuccess: options.success, }); } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 794c282e6e..9f536fd548 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -725,7 +725,8 @@ function submitFormData(fields, options) { // Only used if file / image upload is required 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; diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index b31eb704a9..ec5d74f1de 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -12,7 +12,6 @@ handleFormErrors, imageHoverIcon, inventreeGet, - inventreeMultiDelete, inventreePut, launchModalForm, linkButtonsToSelection, @@ -1107,12 +1106,23 @@ function adjustStock(action, items, options={}) { // Delete action is handled differently if (action == 'delete') { - inventreeMultiDelete( + var ids = []; + + items.forEach(function(item) { + ids.push(item.pk); + }); + + showModalSpinner(opts.modal, true); + inventreeDelete( '{% url "api-stock-list" %}', - items, { - modal: opts.modal, - success: options.success, + data: { + items: ids, + }, + success: function(response) { + $(opts.modal).modal('hide'); + options.success(response); + } } );