diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 1a46da67e7..c8d211922f 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,18 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 323
+INVENTREE_API_VERSION = 324
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v324 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9320
+ - Adds BulkUpdate support for the SalesOrderAllocation model
+ - Adds BulkUpdate support for the PartCategory model
+ - Adds BulkUpdate support for the StockLocation model
+
v323 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9313
- Adds BulkUpdate support to the Part API endpoint
- Remove legacy API endpoint to set part category for multiple parts
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 71fc522886..0c2130a0d4 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -21,7 +21,7 @@ import common.settings
import company.models
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
-from InvenTree.api import ListCreateDestroyAPIView, MetadataView
+from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
@@ -1178,7 +1178,7 @@ class SalesOrderAllocationMixin:
return queryset
-class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
+class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListAPI):
"""API endpoint for listing SalesOrderAllocation objects."""
filterset_class = SalesOrderAllocationFilter
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index 22094273b3..f50a30f8b5 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -226,7 +226,7 @@ class CategoryFilter(rest_filters.FilterSet):
return queryset
-class CategoryList(CategoryMixin, DataExportViewMixin, ListCreateAPI):
+class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py
index 88637db49e..386b6d1bc2 100644
--- a/src/backend/InvenTree/stock/api.py
+++ b/src/backend/InvenTree/stock/api.py
@@ -27,7 +27,7 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
-from InvenTree.api import ListCreateDestroyAPIView, MetadataView
+from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER,
@@ -365,7 +365,9 @@ class StockLocationMixin:
return queryset
-class StockLocationList(DataExportViewMixin, StockLocationMixin, ListCreateAPI):
+class StockLocationList(
+ DataExportViewMixin, BulkUpdateMixin, StockLocationMixin, ListCreateAPI
+):
"""API endpoint for list view of StockLocation objects.
- GET: Return list of StockLocation objects
diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts
index 663c533880..6a819597d8 100644
--- a/src/frontend/playwright.config.ts
+++ b/src/frontend/playwright.config.ts
@@ -43,7 +43,8 @@ export default defineConfig({
INVENTREE_ADMIN_URL: 'test-admin',
INVENTREE_SITE_URL: 'http://localhost:8000',
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
- INVENTREE_COOKIE_SAMESITE: 'Lax'
+ INVENTREE_COOKIE_SAMESITE: 'Lax',
+ INVENTREE_LOGIN_ATTEMPTS: '100'
},
url: 'http://127.0.0.1:8000/api/',
reuseExistingServer: !process.env.CI,
diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
index bcf40c747f..34cd1e0138 100644
--- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx
@@ -213,6 +213,7 @@ export default function SalesOrderShipmentDetail() {
icon: ,
content: (
,
content: detailsPanel
},
+ {
+ name: 'sublocations',
+ label: t`Stock Locations`,
+ icon: ,
+ content:
+ },
{
name: 'stock-items',
label: t`Stock Items`,
@@ -186,12 +192,6 @@ export default function Stock() {
/>
)
},
- {
- name: 'sublocations',
- label: t`Stock Locations`,
- icon: ,
- content:
- },
{
name: 'default_parts',
label: t`Default Parts`,
diff --git a/src/frontend/src/tables/part/PartCategoryTable.tsx b/src/frontend/src/tables/part/PartCategoryTable.tsx
index be5ed601a1..04c93a1bb4 100644
--- a/src/frontend/src/tables/part/PartCategoryTable.tsx
+++ b/src/frontend/src/tables/part/PartCategoryTable.tsx
@@ -5,12 +5,15 @@ import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton';
+import { ActionDropdown } from '../../components/items/ActionDropdown';
import { ApiIcon } from '../../components/items/ApiIcon';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { partCategoryFields } from '../../forms/PartForms';
+import { InvenTreeIcon } from '../../functions/icons';
import {
+ useBulkEditApiFormModal,
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
@@ -120,10 +123,38 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
onFormSuccess: (record: any) => table.updateRecord(record)
});
+ const setParent = useBulkEditApiFormModal({
+ url: ApiEndpoints.category_list,
+ items: table.selectedIds,
+ title: t`Set Parent Category`,
+ fields: {
+ parent: {}
+ },
+ onFormSuccess: table.refreshTable
+ });
+
const tableActions = useMemo(() => {
const can_add = user.hasAddRole(UserRoles.part_category);
+ const can_edit = user.hasChangeRole(UserRoles.part_category);
return [
+ }
+ disabled={!table.hasSelectedRecords}
+ actions={[
+ {
+ name: t`Set Parent`,
+ icon: ,
+ tooltip: t`Set parent category for the selected items`,
+ hidden: !can_edit,
+ disabled: !table.hasSelectedRecords,
+ onClick: () => {
+ setParent.open();
+ }
+ }
+ ]}
+ />,
) {
hidden={!can_add}
/>
];
- }, [user]);
+ }, [user, table.hasSelectedRecords]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@@ -154,12 +185,14 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
<>
{newCategory.modal}
{editCategory.modal}
+ {setParent.modal}
{
- if (!allowEdit) {
- return [];
- }
+ // A subset of the selected allocations, which can be assigned to a shipment
+ const nonShippedAllocationIds: number[] = useMemo(() => {
+ // Only allow allocations which have not been shipped
+ return (
+ table.selectedRecords?.filter((record) => {
+ return !record.shipment_detail?.shipment_date;
+ }) ?? []
+ ).map((record: any) => record.pk);
+ }, [table.selectedRecords]);
- return [];
- }, [allowEdit, user]);
+ const setShipment = useBulkEditApiFormModal({
+ url: ApiEndpoints.sales_order_allocation_list,
+ items: nonShippedAllocationIds,
+ title: t`Assign to Shipment`,
+ fields: {
+ shipment: {
+ filters: {
+ order: orderId,
+ shipped: false
+ }
+ }
+ },
+ onFormSuccess: table.refreshTable
+ });
+
+ const tableActions = useMemo(() => {
+ return [
+ }
+ onClick={() => {
+ setShipment.open();
+ }}
+ disabled={nonShippedAllocationIds.length == 0}
+ hidden={
+ !orderId || !allowEdit || !user.hasChangeRole(UserRoles.sales_order)
+ }
+ // TODO: Hide if order is already shipped
+ />
+ ];
+ }, [allowEdit, nonShippedAllocationIds, orderId, user]);
return (
<>
+ {setShipment.modal}
{editAllocation.modal}
{deleteAllocation.modal}
) {
onFormSuccess: (record: any) => table.updateRecord(record)
});
+ const setParent = useBulkEditApiFormModal({
+ url: ApiEndpoints.stock_location_list,
+ items: table.selectedIds,
+ title: t`Set Parent Location`,
+ fields: {
+ parent: {}
+ },
+ onFormSuccess: table.refreshTable
+ });
+
const tableActions = useMemo(() => {
const can_add = user.hasAddRole(UserRoles.stock_location);
+ const can_edit = user.hasChangeRole(UserRoles.stock_location);
return [
+ }
+ disabled={!table.hasSelectedRecords}
+ actions={[
+ {
+ name: t`Set Parent`,
+ icon: ,
+ tooltip: t`Set parent location for the selected items`,
+ hidden: !can_edit,
+ disabled: !table.hasSelectedRecords,
+ onClick: () => {
+ setParent.open();
+ }
+ }
+ ]}
+ />,
) {
hidden={!can_add}
/>
];
- }, [user]);
+ }, [user, table.hasSelectedRecords]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@@ -152,6 +183,7 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
<>
{newLocation.modal}
{editLocation.modal}
+ {setParent.modal}