mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +00:00 
			
		
		
		
	[UI] Bulk edit actions (#9320)
* Allow bulk selection of sales order shipment * Tweaks * Support bulk-edit for location parent and category parent * Allow more login attempts for playwright
This commit is contained in:
		@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -213,6 +213,7 @@ export default function SalesOrderShipmentDetail() {
 | 
			
		||||
        icon: <IconBookmark />,
 | 
			
		||||
        content: (
 | 
			
		||||
          <SalesOrderAllocationTable
 | 
			
		||||
            orderId={shipment.order}
 | 
			
		||||
            shipmentId={shipment.pk}
 | 
			
		||||
            showPartInfo
 | 
			
		||||
            allowEdit={isPending}
 | 
			
		||||
 
 | 
			
		||||
@@ -172,6 +172,12 @@ export default function Stock() {
 | 
			
		||||
        icon: <IconInfoCircle />,
 | 
			
		||||
        content: detailsPanel
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'sublocations',
 | 
			
		||||
        label: t`Stock Locations`,
 | 
			
		||||
        icon: <IconSitemap />,
 | 
			
		||||
        content: <StockLocationTable parentId={id} />
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'stock-items',
 | 
			
		||||
        label: t`Stock Items`,
 | 
			
		||||
@@ -186,12 +192,6 @@ export default function Stock() {
 | 
			
		||||
          />
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'sublocations',
 | 
			
		||||
        label: t`Stock Locations`,
 | 
			
		||||
        icon: <IconSitemap />,
 | 
			
		||||
        content: <StockLocationTable parentId={id} />
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'default_parts',
 | 
			
		||||
        label: t`Default Parts`,
 | 
			
		||||
 
 | 
			
		||||
@@ -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 [
 | 
			
		||||
      <ActionDropdown
 | 
			
		||||
        tooltip={t`Category Actions`}
 | 
			
		||||
        icon={<InvenTreeIcon icon='category' />}
 | 
			
		||||
        disabled={!table.hasSelectedRecords}
 | 
			
		||||
        actions={[
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Set Parent`,
 | 
			
		||||
            icon: <InvenTreeIcon icon='category' />,
 | 
			
		||||
            tooltip: t`Set parent category for the selected items`,
 | 
			
		||||
            hidden: !can_edit,
 | 
			
		||||
            disabled: !table.hasSelectedRecords,
 | 
			
		||||
            onClick: () => {
 | 
			
		||||
              setParent.open();
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
      <AddItemButton
 | 
			
		||||
        key='add-part-category'
 | 
			
		||||
        tooltip={t`Add Part Category`}
 | 
			
		||||
@@ -131,7 +162,7 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) {
 | 
			
		||||
        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}
 | 
			
		||||
      <InvenTreeTable
 | 
			
		||||
        url={apiUrl(ApiEndpoints.category_list)}
 | 
			
		||||
        tableState={table}
 | 
			
		||||
        columns={tableColumns}
 | 
			
		||||
        props={{
 | 
			
		||||
          enableDownload: true,
 | 
			
		||||
          enableSelection: true,
 | 
			
		||||
          params: {
 | 
			
		||||
            parent: parentId,
 | 
			
		||||
            top_level: parentId === undefined ? true : undefined
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,15 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
import { IconTruckDelivery } from '@tabler/icons-react';
 | 
			
		||||
import { ActionButton } from '../../components/buttons/ActionButton';
 | 
			
		||||
import { formatDate } from '../../defaults/formatters';
 | 
			
		||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
			
		||||
import { ModelType } from '../../enums/ModelType';
 | 
			
		||||
import { UserRoles } from '../../enums/Roles';
 | 
			
		||||
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
 | 
			
		||||
import {
 | 
			
		||||
  useBulkEditApiFormModal,
 | 
			
		||||
  useDeleteApiFormModal,
 | 
			
		||||
  useEditApiFormModal
 | 
			
		||||
} from '../../hooks/UseForm';
 | 
			
		||||
@@ -244,16 +248,51 @@ export default function SalesOrderAllocationTable({
 | 
			
		||||
    [allowEdit, user]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const tableActions = useMemo(() => {
 | 
			
		||||
    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 [
 | 
			
		||||
      <ActionButton
 | 
			
		||||
        tooltip={t`Assign to shipment`}
 | 
			
		||||
        icon={<IconTruckDelivery />}
 | 
			
		||||
        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}
 | 
			
		||||
      <InvenTreeTable
 | 
			
		||||
@@ -277,9 +316,10 @@ export default function SalesOrderAllocationTable({
 | 
			
		||||
          enableColumnSwitching: !isSubTable,
 | 
			
		||||
          enableFilters: !isSubTable,
 | 
			
		||||
          enableDownload: !isSubTable,
 | 
			
		||||
          enableSelection: !isSubTable,
 | 
			
		||||
          minHeight: isSubTable ? 100 : undefined,
 | 
			
		||||
          rowActions: rowActions,
 | 
			
		||||
          tableActions: tableActions,
 | 
			
		||||
          tableActions: isSubTable ? undefined : tableActions,
 | 
			
		||||
          tableFilters: tableFilters,
 | 
			
		||||
          modelField: modelField ?? 'order',
 | 
			
		||||
          modelType: modelTarget ?? ModelType.salesorder
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,15 @@ import { Group } from '@mantine/core';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
			
		||||
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 { stockLocationFields } from '../../forms/StockForms';
 | 
			
		||||
import { InvenTreeIcon } from '../../functions/icons';
 | 
			
		||||
import {
 | 
			
		||||
  useBulkEditApiFormModal,
 | 
			
		||||
  useCreateApiFormModal,
 | 
			
		||||
  useEditApiFormModal
 | 
			
		||||
} from '../../hooks/UseForm';
 | 
			
		||||
@@ -118,10 +121,38 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
 | 
			
		||||
    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 [
 | 
			
		||||
      <ActionDropdown
 | 
			
		||||
        tooltip={t`Location Actions`}
 | 
			
		||||
        icon={<InvenTreeIcon icon='location' />}
 | 
			
		||||
        disabled={!table.hasSelectedRecords}
 | 
			
		||||
        actions={[
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Set Parent`,
 | 
			
		||||
            icon: <InvenTreeIcon icon='location' />,
 | 
			
		||||
            tooltip: t`Set parent location for the selected items`,
 | 
			
		||||
            hidden: !can_edit,
 | 
			
		||||
            disabled: !table.hasSelectedRecords,
 | 
			
		||||
            onClick: () => {
 | 
			
		||||
              setParent.open();
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
      <AddItemButton
 | 
			
		||||
        key='add-stock-location'
 | 
			
		||||
        tooltip={t`Add Stock Location`}
 | 
			
		||||
@@ -129,7 +160,7 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
 | 
			
		||||
        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}
 | 
			
		||||
      <InvenTreeTable
 | 
			
		||||
        url={apiUrl(ApiEndpoints.stock_location_list)}
 | 
			
		||||
        tableState={table}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user