diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index a102dbf764bd..e534bf4bc764 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -166,7 +166,7 @@ jobs: - name: Push Docker Images id: push-docker if: github.event_name != 'pull_request' - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # pin@v6.9.0 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # pin@v6.10.0 with: context: . file: ./contrib/container/Dockerfile diff --git a/contrib/dev_reqs/requirements.in b/contrib/dev_reqs/requirements.in index 06161673d92f..f29d26bb06ec 100644 --- a/contrib/dev_reqs/requirements.in +++ b/contrib/dev_reqs/requirements.in @@ -1,4 +1,4 @@ # Packages needed for CI/packages requests==2.32.3 pyyaml==6.0.2 -jc==1.25.3 +jc==1.25.4 diff --git a/contrib/dev_reqs/requirements.txt b/contrib/dev_reqs/requirements.txt index 805bbba36994..465d631145b9 100644 --- a/contrib/dev_reqs/requirements.txt +++ b/contrib/dev_reqs/requirements.txt @@ -115,9 +115,9 @@ idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 # via requests -jc==1.25.3 \ - --hash=sha256:ea17a8578497f2da92f73924d9d403f4563ba59422fbceff7bb4a16cdf84a54f \ - --hash=sha256:fa3140ceda6cba1210d1362f363cd79a0514741e8a1dd6167db2b2e2d5f24f7b +jc==1.25.4 \ + --hash=sha256:1e4f45d2e5b72cf9d300b0d9df0578c0d3b553843e3ad37a525d93bb0e94aca1 \ + --hash=sha256:a32eaf029c56b582dadae48895f20784d0f84f2fa28a8e2b32f377a8bffa8b39 # via -r contrib/dev_reqs/requirements.in pygments==2.18.0 \ --hash=sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199 \ diff --git a/docs/requirements.txt b/docs/requirements.txt index c79a3ddbbc20..a32343649871 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -311,17 +311,17 @@ mkdocs-git-revision-date-localized-plugin==1.3.0 \ --hash=sha256:439e2f14582204050a664c258861c325064d97cdc848c541e48bb034a6c4d0cb \ --hash=sha256:c99377ee119372d57a9e47cff4e68f04cce634a74831c06bc89b33e456e840a1 # via -r docs/requirements.in -mkdocs-include-markdown-plugin==7.1.1 \ - --hash=sha256:046a452dea2796e93f1385a1db106209a18bb9417162063ffe0a432a97c9b837 \ - --hash=sha256:3ca17da4d5d77cfa5f4da564e65dc74ee2aa6a7368119db23d650fb24d95fce9 +mkdocs-include-markdown-plugin==7.1.2 \ + --hash=sha256:1b393157b1aa231b0e6c59ba80f52b723f4b7827bb7a1264b505334f8542aaf1 \ + --hash=sha256:ff1175d1b4f83dea6a38e200d6f0c3db10308975bf60c197d31172671753dbc4 # via -r docs/requirements.in mkdocs-macros-plugin==1.3.7 \ --hash=sha256:02432033a5b77fb247d6ec7924e72fc4ceec264165b1644ab8d0dc159c22ce59 \ --hash=sha256:17c7fd1a49b94defcdb502fd453d17a1e730f8836523379d21292eb2be4cb523 # via -r docs/requirements.in -mkdocs-material==9.5.46 \ - --hash=sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83 \ - --hash=sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7 +mkdocs-material==9.5.47 \ + --hash=sha256:53fb9c9624e7865da6ec807d116cd7be24b3cb36ab31b1d1d1a9af58c56009a2 \ + --hash=sha256:fc3b7a8e00ad896660bd3a5cc12ca0cb28bdc2bcbe2a946b5714c23ac91b0ede # via -r docs/requirements.in mkdocs-material-extensions==1.3.1 \ --hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \ diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d937a1a4160b..2d7b716adce2 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 291 +INVENTREE_API_VERSION = 292 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v292 - 2024-12-03 : https://github.com/inventree/InvenTree/pull/8625 + - Add "on_order" and "in_stock" annotations to SupplierPart API + - Enhanced filtering for the SupplierPart API + v291 - 2024-11-30 : https://github.com/inventree/InvenTree/pull/8596 - Allow null / empty values for plugin settings diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 5765b11b85db..9ff22f6e8afe 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -282,6 +282,7 @@ class Meta: field_name='part__active', label=_('Internal Part is Active') ) + # Filter by 'active' status of linked supplier supplier_active = rest_filters.BooleanFilter( field_name='supplier__active', label=_('Supplier is Active') ) @@ -293,43 +294,48 @@ class Meta: lookup_expr='iexact', ) + # Filter by 'manufacturer' + manufacturer = rest_filters.ModelChoiceFilter( + label=_('Manufacturer'), + queryset=Company.objects.all(), + field_name='manufacturer_part__manufacturer', + ) -class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView): - """API endpoint for list view of SupplierPart object. + # Filter by 'company' (either manufacturer or supplier) + company = rest_filters.ModelChoiceFilter( + label=_('Company'), queryset=Company.objects.all(), method='filter_company' + ) - - GET: Return list of SupplierPart objects - - POST: Create a new SupplierPart object - """ + def filter_company(self, queryset, name, value): + """Filter the queryset by either manufacturer or supplier.""" + return queryset.filter( + Q(manufacturer_part__manufacturer=value) | Q(supplier=value) + ).distinct() + + has_stock = rest_filters.BooleanFilter( + label=_('Has Stock'), method='filter_has_stock' + ) + + def filter_has_stock(self, queryset, name, value): + """Filter the queryset based on whether the SupplierPart has stock available.""" + if value: + return queryset.filter(in_stock__gt=0) + else: + return queryset.exclude(in_stock__gt=0) + + +class SupplierPartMixin: + """Mixin class for SupplierPart API endpoints.""" queryset = SupplierPart.objects.all().prefetch_related('tags') - filterset_class = SupplierPartFilter + serializer_class = SupplierPartSerializer def get_queryset(self, *args, **kwargs): """Return annotated queryest object for the SupplierPart list.""" queryset = super().get_queryset(*args, **kwargs) queryset = SupplierPartSerializer.annotate_queryset(queryset) - return queryset - - def filter_queryset(self, queryset): - """Custom filtering for the queryset.""" - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter by manufacturer - manufacturer = params.get('manufacturer', None) - - if manufacturer is not None: - queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer) - - # Filter by EITHER manufacturer or supplier - company = params.get('company', None) - - if company is not None: - queryset = queryset.filter( - Q(manufacturer_part__manufacturer=company) | Q(supplier=company) - ).distinct() + queryset = queryset.prefetch_related('part', 'part__pricing_data') return queryset @@ -351,7 +357,17 @@ def get_serializer(self, *args, **kwargs): return self.serializer_class(*args, **kwargs) - serializer_class = SupplierPartSerializer + +class SupplierPartList( + DataExportViewMixin, SupplierPartMixin, ListCreateDestroyAPIView +): + """API endpoint for list view of SupplierPart object. + + - GET: Return list of SupplierPart objects + - POST: Create a new SupplierPart object + """ + + filterset_class = SupplierPartFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS @@ -391,7 +407,7 @@ def get_serializer(self, *args, **kwargs): ] -class SupplierPartDetail(RetrieveUpdateDestroyAPI): +class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of SupplierPart object. - GET: Retrieve detail view @@ -399,11 +415,6 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI): - DELETE: Delete object """ - queryset = SupplierPart.objects.all() - serializer_class = SupplierPartSerializer - - read_only_fields = [] - class SupplierPriceBreakFilter(rest_filters.FilterSet): """Custom API filters for the SupplierPriceBreak list endpoint.""" diff --git a/src/backend/InvenTree/company/filters.py b/src/backend/InvenTree/company/filters.py new file mode 100644 index 000000000000..e9990c0baa8c --- /dev/null +++ b/src/backend/InvenTree/company/filters.py @@ -0,0 +1,36 @@ +"""Custom query filters for the Company app.""" + +from decimal import Decimal + +from django.db.models import DecimalField, ExpressionWrapper, F, Q +from django.db.models.functions import Coalesce + +from sql_util.utils import SubquerySum + +from order.status_codes import PurchaseOrderStatusGroups + + +def annotate_on_order_quantity(): + """Annotate the 'on_order' quantity for each SupplierPart in a queryset. + + - This is the total quantity of parts on order from all open purchase orders + - Takes into account the 'received' quantity for each order line + """ + # Filter only 'active' purhase orders + # Filter only line with outstanding quantity + order_filter = Q( + order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') + ) + + return Coalesce( + SubquerySum( + ExpressionWrapper( + F('purchase_order_line_items__quantity') + - F('purchase_order_line_items__received'), + output_field=DecimalField(), + ), + filter=order_filter, + ), + Decimal(0), + output_field=DecimalField(), + ) diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index da763f8d7b73..dd05243454d7 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -9,6 +9,7 @@ from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField +import company.filters import part.filters import part.serializers as part_serializers from importer.mixins import DataImportExportSerializerMixin @@ -323,6 +324,7 @@ class Meta: 'availability_updated', 'description', 'in_stock', + 'on_order', 'link', 'active', 'manufacturer', @@ -396,6 +398,8 @@ def __init__(self, *args, **kwargs): # Annotated field showing total in-stock quantity in_stock = serializers.FloatField(read_only=True, label=_('In Stock')) + on_order = serializers.FloatField(read_only=True, label=_('On Order')) + available = serializers.FloatField(required=False, label=_('Available')) pack_quantity_native = serializers.FloatField(read_only=True) @@ -442,6 +446,10 @@ def annotate_queryset(queryset): """ queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock()) + queryset = queryset.annotate( + on_order=company.filters.annotate_on_order_quantity() + ) + return queryset def update(self, supplier_part, data): diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 2a35b80714ab..b7b53a293a7b 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -1,4 +1,4 @@ -"""Custom query filters for the Part models. +"""Custom query filters for the Part app. The code here makes heavy use of subquery annotations! diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 8b466908642f..17cccb4e2f0d 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -1251,9 +1251,9 @@ pydyf==0.10.0 \ # via # -r src/backend/requirements.in # weasyprint -pyjwt==2.10.0 \ - --hash=sha256:543b77207db656de204372350926bed5a86201c4cbff159f623f79c7bb487a15 \ - --hash=sha256:7628a7eb7938959ac1b26e819a1df0fd3259505627b575e4bad6d08f76db695c +pyjwt==2.10.1 \ + --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ + --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb # via djangorestframework-simplejwt pyphen==0.17.0 \ --hash=sha256:1d13acd1ce37a384d7612954ae6c7801bb4c5316da0e2b937b2127ba702a3da4 \ diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 9d95f3a8f185..231520f2be3a 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -183,10 +183,25 @@ export default function SupplierPartDetail() { ]; const br: DetailsField[] = [ + { + type: 'string', + name: 'in_stock', + label: t`In Stock`, + copy: true, + icon: 'stock' + }, + { + type: 'string', + name: 'on_order', + label: t`On Order`, + copy: true, + icon: 'purchase_orders' + }, { type: 'string', name: 'available', label: t`Supplier Availability`, + hidden: !data.availability_updated, copy: true, icon: 'packages' }, @@ -352,6 +367,28 @@ export default function SupplierPartDetail() { label={t`Inactive`} color='red' visible={supplierPart.active == false} + />, + 0 + } + key='in_stock' + />, + , + 0} + key='on_order' /> ]; }, [supplierPart]); diff --git a/src/frontend/src/tables/Column.tsx b/src/frontend/src/tables/Column.tsx index 6c9b6f9ea424..48a872876b57 100644 --- a/src/frontend/src/tables/Column.tsx +++ b/src/frontend/src/tables/Column.tsx @@ -18,6 +18,7 @@ export type TableColumnProps = { textAlign?: 'left' | 'center' | 'right'; // The text alignment of the column cellsStyle?: any; // The style of the cells in the column extra?: any; // Extra data to pass to the render function + noContext?: boolean; // Disable context menu for this column }; /** diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 72983c132e79..bceb9a9d2f36 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -88,7 +88,7 @@ export type InvenTreeTableProps = { modelType?: ModelType; rowStyle?: (record: T, index: number) => any; modelField?: string; - onRowContextMenu?: (record: T, event: any) => void; + onCellContextMenu?: (record: T, event: any) => void; minHeight?: number; noHeader?: boolean; }; @@ -568,16 +568,21 @@ export function InvenTreeTable>({ [props.onRowClick, props.onCellClick] ); - // Callback when a row is right-clicked - const handleRowContextMenu = ({ + // Callback when a cell is right-clicked + const handleCellContextMenu = ({ record, + column, event }: { record: any; + column: any; event: any; }) => { - if (props.onRowContextMenu) { - return props.onRowContextMenu(record, event); + if (column?.noContext === true) { + return; + } + if (props.onCellContextMenu) { + return props.onCellContextMenu(record, event); } else if (props.rowActions) { const empty = () => {}; const items = props.rowActions(record).map((action) => ({ @@ -693,7 +698,7 @@ export function InvenTreeTable>({ overflow: 'hidden' }) }} - onRowContextMenu={handleRowContextMenu} + onCellContextMenu={handleCellContextMenu} {...optionalParams} /> diff --git a/src/frontend/src/tables/general/AttachmentTable.tsx b/src/frontend/src/tables/general/AttachmentTable.tsx index dbd4f7784d1f..3e19e45adc4c 100644 --- a/src/frontend/src/tables/general/AttachmentTable.tsx +++ b/src/frontend/src/tables/general/AttachmentTable.tsx @@ -48,7 +48,8 @@ function attachmentTableColumns(): TableColumn[] { } else { return '-'; } - } + }, + noContext: true }, { accessor: 'comment', diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 1e23746805c7..f8219cdf8f40 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -199,6 +199,11 @@ export function SupplierPartTable({ name: 'supplier_active', label: t`Active Supplier`, description: t`Show active suppliers` + }, + { + name: 'has_stock', + label: t`In Stock`, + description: t`Show supplier parts with stock` } ]; }, []); diff --git a/src/frontend/src/tables/settings/ImportSessionTable.tsx b/src/frontend/src/tables/settings/ImportSessionTable.tsx index 87deee0a96a7..8a99ca1d12c3 100644 --- a/src/frontend/src/tables/settings/ImportSessionTable.tsx +++ b/src/frontend/src/tables/settings/ImportSessionTable.tsx @@ -61,7 +61,8 @@ export default function ImportSesssionTable() { render: (record: any) => ( ), - sortable: false + sortable: false, + noContext: true }, DateColumn({ accessor: 'timestamp', diff --git a/src/frontend/src/tables/settings/StocktakeReportTable.tsx b/src/frontend/src/tables/settings/StocktakeReportTable.tsx index ae0678b059b2..e31e54bc145d 100644 --- a/src/frontend/src/tables/settings/StocktakeReportTable.tsx +++ b/src/frontend/src/tables/settings/StocktakeReportTable.tsx @@ -28,7 +28,8 @@ export default function StocktakeReportTable() { title: t`Report`, sortable: false, switchable: false, - render: (record: any) => + render: (record: any) => , + noContext: true }, { accessor: 'part_count', diff --git a/src/frontend/src/tables/settings/TemplateTable.tsx b/src/frontend/src/tables/settings/TemplateTable.tsx index 70b86cd6bb85..162a09164666 100644 --- a/src/frontend/src/tables/settings/TemplateTable.tsx +++ b/src/frontend/src/tables/settings/TemplateTable.tsx @@ -219,7 +219,8 @@ export function TemplateTable({ } return ; - } + }, + noContext: true }, { accessor: 'model_type', diff --git a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx index 46863385f945..c6bef2aea8f5 100644 --- a/src/frontend/src/tables/stock/StockItemTestResultTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTestResultTable.tsx @@ -195,7 +195,10 @@ export default function StockItemTestResultTable({ accessor: 'attachment', title: t`Attachment`, render: (record: any) => - record.attachment && + record.attachment && ( + + ), + noContext: true }, NoteColumn({}), DateColumn({}),