Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-06-07 08:20:32 +10:00
commit eb133ce185
24 changed files with 450 additions and 193 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,30 +851,30 @@ 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
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):
"""Model for storing file attachments against a PurchaseOrder object."""

View File

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

View File

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

View File

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

View File

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

View File

@ -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" %}
</div>`;
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,
});
}
}

View File

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

View 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 %}

View File

@ -7,7 +7,6 @@
/* exported
inventreeGet,
inventreeDelete,
inventreeMultiDelete,
inventreeFormDataUpload,
showApiError,
*/
@ -160,59 +159,20 @@ function inventreePut(url, data={}, options={}) {
}
function inventreeDelete(url, options={}) {
/*
* Delete a record
* Performs a DELETE API call to the server
*/
function inventreeDelete(url, options={}) {
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
);
}

View File

@ -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,23 +107,17 @@ function deleteAttachments(attachments, url, options={}) {
</table>
`;
constructFormBody({}, {
constructForm(url, {
method: 'DELETE',
title: '{% trans "Delete Attachments" %}',
preFormContent: html,
onSubmit: function(fields, opts) {
inventreeMultiDelete(
url,
attachments,
{
modal: opts.modal,
success: function() {
form_data: {
items: ids,
},
onSuccess: function() {
// Refresh the table once all attachments are deleted
$('#attachment-table').bootstrapTable('refresh');
}
}
);
}
});
}

View File

@ -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={}) {
</table>
`;
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,
});
}

View File

@ -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={}) {
</table>
`;
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={}) {
</table>
`;
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={}) {
</table>
`;
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,
});
}

View File

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

View File

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