From ddcb7980ffae24306c1de8ca940c4ecfc998ec17 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 27 Jan 2025 14:03:40 +1100 Subject: [PATCH] [Refactoring] Data Export (#8950) * Allow extraction of "child" fields when exporting serialized data * Update StockItemSerializer * Add missing default attribute * Cleanup export for BuildItemSerializer * Refactor BuildLineSerializer * Refactor BomItemSerializer * Auto-exclude tags from export (for now) * Cleanup SupplierPartSerializer * Updated unit test * Cleanup * Bump API version * Reduce serializer complexity * Refactor StockLocation API endpoints * Cleanup API * Enhanced docstrings --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/InvenTree/models.py | 25 ++- src/backend/InvenTree/build/serializers.py | 162 +++++++----------- src/backend/InvenTree/company/serializers.py | 37 ++-- src/backend/InvenTree/importer/mixins.py | 73 +++++++- src/backend/InvenTree/part/filters.py | 2 +- src/backend/InvenTree/part/serializers.py | 27 ++- src/backend/InvenTree/stock/api.py | 99 +++++------ src/backend/InvenTree/stock/serializers.py | 61 ++++--- src/backend/InvenTree/stock/test_api.py | 34 +++- .../src/pages/company/SupplierPartDetail.tsx | 3 +- 11 files changed, 285 insertions(+), 244 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5cd04449c7..666567f6b0 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 = 304 +INVENTREE_API_VERSION = 305 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v305 - 2025-01-26 : https://github.com/inventree/InvenTree/pull/8950 + - Bug fixes for the SupplierPart API + - Refactoring for data export via API + v304 - 2025-01-22 : https://github.com/inventree/InvenTree/pull/8940 - Adds "category" filter to build list API diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 3840075712..ba42f89a27 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models +from django.db.models import QuerySet from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse @@ -820,26 +821,20 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): """ raise NotImplementedError(f'items() method not implemented for {type(self)}') - def getUniqueParents(self): - """Return a flat set of all parent items that exist above this node. - - If any parents are repeated (which would be very bad!), the process is halted - """ + def getUniqueParents(self) -> QuerySet: + """Return a flat set of all parent items that exist above this node.""" return self.get_ancestors() - def getUniqueChildren(self, include_self=True): - """Return a flat set of all child items that exist under this node. - - If any child items are repeated, the repetitions are omitted. - """ + def getUniqueChildren(self, include_self=True) -> QuerySet: + """Return a flat set of all child items that exist under this node.""" return self.get_descendants(include_self=include_self) @property - def has_children(self): + def has_children(self) -> bool: """True if there are any children under this item.""" return self.getUniqueChildren(include_self=False).count() > 0 - def getAcceptableParents(self): + def getAcceptableParents(self) -> list: """Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item. Setting the parent of an item to its own child results in recursion. @@ -860,7 +855,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): return acceptable @property - def parentpath(self): + def parentpath(self) -> list: """Get the parent path of this category. Returns: @@ -869,7 +864,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): return list(self.get_ancestors()) @property - def path(self): + def path(self) -> list: """Get the complete part of this category. e.g. ["Top", "Second", "Third", "This"] @@ -879,7 +874,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): """ return [*self.parentpath, self] - def get_path(self): + def get_path(self) -> list: """Return a list of element in the item tree. Contains the full path to this item, with each entry containing the following data: diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index a3844b0677..613eb4dec2 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -1152,24 +1152,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer): class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): """Serializes a BuildItem object, which is an allocation of a stock item against a build order.""" - # These fields are only used for data export - export_only_fields = [ - 'bom_part_id', - 'bom_part_name', - 'build_reference', - 'sku', - 'mpn', - 'location_name', - 'part_id', - 'part_name', - 'part_ipn', - 'part_description', - 'available_quantity', - 'item_batch_code', - 'item_serial', - 'item_packaging', + export_child_fields = [ + 'build_detail.reference', + 'location_detail.name', + 'part_detail.name', + 'part_detail.description', + 'part_detail.IPN', + 'stock_item_detail.batch', + 'stock_item_detail.packaging', + 'stock_item_detail.part', + 'stock_item_detail.quantity', + 'stock_item_detail.serial', + 'supplier_part_detail.SKU', + 'supplier_part_detail.MPN', ] + # These fields are only used for data export + export_only_fields = ['bom_part_id', 'bom_part_name'] + class Meta: """Serializer metaclass.""" @@ -1192,18 +1192,6 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'bom_reference', 'bom_part_id', 'bom_part_name', - 'build_reference', - 'location_name', - 'mpn', - 'sku', - 'part_id', - 'part_name', - 'part_ipn', - 'part_description', - 'available_quantity', - 'item_batch_code', - 'item_serial_number', - 'item_packaging', ] def __init__(self, *args, **kwargs): @@ -1211,7 +1199,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali part_detail = kwargs.pop('part_detail', True) location_detail = kwargs.pop('location_detail', True) stock_detail = kwargs.pop('stock_detail', True) - build_detail = kwargs.pop('build_detail', False) + build_detail = kwargs.pop('build_detail', True) super().__init__(*args, **kwargs) @@ -1228,44 +1216,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali self.fields.pop('build_detail', None) # Export-only fields - sku = serializers.CharField( - source='stock_item.supplier_part.SKU', - label=_('Supplier Part Number'), - read_only=True, - ) - mpn = serializers.CharField( - source='stock_item.supplier_part.manufacturer_part.MPN', - label=_('Manufacturer Part Number'), - read_only=True, - ) - location_name = serializers.CharField( - source='stock_item.location.name', label=_('Location Name'), read_only=True - ) - build_reference = serializers.CharField( - source='build.reference', label=_('Build Reference'), read_only=True - ) bom_reference = serializers.CharField( source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True ) - item_packaging = serializers.CharField( - source='stock_item.packaging', label=_('Packaging'), read_only=True - ) - - # Part detail fields - part_id = serializers.PrimaryKeyRelatedField( - source='stock_item.part', label=_('Part ID'), many=False, read_only=True - ) - part_name = serializers.CharField( - source='stock_item.part.name', label=_('Part Name'), read_only=True - ) - part_ipn = serializers.CharField( - source='stock_item.part.IPN', label=_('Part IPN'), read_only=True - ) - part_description = serializers.CharField( - source='stock_item.part.description', - label=_('Part Description'), - read_only=True, - ) # BOM Item Part ID (it may be different to the allocated part) bom_part_id = serializers.PrimaryKeyRelatedField( @@ -1274,19 +1227,13 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali many=False, read_only=True, ) + bom_part_name = serializers.CharField( source='build_line.bom_item.sub_part.name', label=_('BOM Part Name'), read_only=True, ) - item_batch_code = serializers.CharField( - source='stock_item.batch', label=_('Batch Code'), read_only=True - ) - item_serial_number = serializers.CharField( - source='stock_item.serial', label=_('Serial Number'), read_only=True - ) - # Annotated fields build = serializers.PrimaryKeyRelatedField( source='build_line.build', many=False, read_only=True @@ -1294,26 +1241,38 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Extra (optional) detail fields part_detail = part_serializers.PartBriefSerializer( - source='stock_item.part', many=False, read_only=True, pricing=False + label=_('Part'), + source='stock_item.part', + many=False, + read_only=True, + pricing=False, ) - stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) + + stock_item_detail = StockItemSerializerBrief( + source='stock_item', read_only=True, label=_('Stock Item') + ) + location = serializers.PrimaryKeyRelatedField( - source='stock_item.location', many=False, read_only=True + label=_('Location'), source='stock_item.location', many=False, read_only=True ) + location_detail = LocationBriefSerializer( - source='stock_item.location', read_only=True + label=_('Location'), source='stock_item.location', read_only=True ) + build_detail = BuildSerializer( - source='build_line.build', many=False, read_only=True + label=_('Build'), source='build_line.build', many=False, read_only=True ) + supplier_part_detail = company.serializers.SupplierPartSerializer( - source='stock_item.supplier_part', many=False, read_only=True, brief=True + label=_('Supplier Part'), + source='stock_item.supplier_part', + many=False, + read_only=True, + brief=True, ) quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) - available_quantity = InvenTreeDecimalField( - source='stock_item.quantity', read_only=True, label=_('Available Quantity') - ) class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): @@ -1321,7 +1280,16 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali export_exclude_fields = ['allocations'] - export_only_fields = ['part_description', 'part_category_name'] + export_child_fields = [ + 'build_detail.reference', + 'part_detail.name', + 'part_detail.description', + 'part_detail.IPN', + 'part_detail.category', + 'bom_item_detail.reference', + ] + + export_only_fields = ['part_category_name'] class Meta: """Serializer metaclass.""" @@ -1332,6 +1300,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'build', 'bom_item', 'quantity', + 'part', # Build detail fields 'build_reference', # BOM item detail fields @@ -1342,11 +1311,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'trackable', 'inherited', 'allow_variants', - # Part detail fields - 'part', - 'part_name', - 'part_IPN', - 'part_category_id', # Annotated fields 'allocated', 'in_production', @@ -1358,7 +1322,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Related fields 'allocations', # Extra fields only for data export - 'part_description', 'part_category_name', # Extra detail (related field) serializers 'bom_item_detail', @@ -1371,7 +1334,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali def __init__(self, *args, **kwargs): """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) - build_detail = kwargs.pop('build_detail', False) + build_detail = kwargs.pop('build_detail', True) super().__init__(*args, **kwargs) @@ -1390,21 +1353,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali part = serializers.PrimaryKeyRelatedField( source='bom_item.sub_part', label=_('Part'), many=False, read_only=True ) - part_name = serializers.CharField( - source='bom_item.sub_part.name', label=_('Part Name'), read_only=True - ) - part_IPN = serializers.CharField( # noqa: N815 - source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True - ) - part_description = serializers.CharField( - source='bom_item.sub_part.description', - label=_('Part Description'), - read_only=True, - ) - part_category_id = serializers.PrimaryKeyRelatedField( - source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True - ) part_category_name = serializers.CharField( source='bom_item.sub_part.category.name', label=_('Part Category Name'), @@ -1442,6 +1391,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Foreign key fields bom_item_detail = part_serializers.BomItemSerializer( + label=_('BOM Item'), source='bom_item', many=False, read_only=True, @@ -1452,10 +1402,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ) part_detail = part_serializers.PartBriefSerializer( - source='bom_item.sub_part', many=False, read_only=True, pricing=False + label=_('Part'), + source='bom_item.sub_part', + many=False, + read_only=True, + pricing=False, ) build_detail = BuildSerializer( - source='build', part_detail=False, many=False, read_only=True + label=_('Build'), source='build', part_detail=False, many=False, read_only=True ) # Annotated (calculated) fields diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index dd05243454..ce30022d93 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -315,6 +315,16 @@ class SupplierPartSerializer( ): """Serializer for SupplierPart object.""" + export_exclude_fields = ['tags'] + + export_child_fields = [ + 'part_detail.name', + 'part_detail.description', + 'part_detail.IPN', + 'supplier_detail.name', + 'manufacturer_detail.name', + ] + class Meta: """Metaclass options.""" @@ -327,12 +337,10 @@ class SupplierPartSerializer( 'on_order', 'link', 'active', - 'manufacturer', 'manufacturer_detail', 'manufacturer_part', 'manufacturer_part_detail', 'MPN', - 'name', 'note', 'pk', 'barcode_hash', @@ -393,6 +401,7 @@ class SupplierPartSerializer( if brief: self.fields.pop('tags') self.fields.pop('available') + self.fields.pop('on_order') self.fields.pop('availability_updated') # Annotated field showing total in-stock quantity @@ -405,32 +414,36 @@ class SupplierPartSerializer( pack_quantity_native = serializers.FloatField(read_only=True) part_detail = part_serializers.PartBriefSerializer( - source='part', many=False, read_only=True + label=_('Part'), source='part', many=False, read_only=True ) supplier_detail = CompanyBriefSerializer( - source='supplier', many=False, read_only=True + label=_('Supplier'), source='supplier', many=False, read_only=True ) manufacturer_detail = CompanyBriefSerializer( - source='manufacturer_part.manufacturer', many=False, read_only=True + label=_('Manufacturer'), + source='manufacturer_part.manufacturer', + many=False, + read_only=True, ) pretty_name = serializers.CharField(read_only=True) supplier = serializers.PrimaryKeyRelatedField( - queryset=Company.objects.filter(is_supplier=True) + label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True) ) - manufacturer = serializers.CharField(read_only=True) - - MPN = serializers.CharField(read_only=True) - manufacturer_part_detail = ManufacturerPartSerializer( - source='manufacturer_part', part_detail=False, read_only=True + label=_('Manufacturer Part'), + source='manufacturer_part', + part_detail=False, + read_only=True, ) - name = serializers.CharField(read_only=True) + MPN = serializers.CharField( + source='manufacturer_part.MPN', read_only=True, label=_('MPN') + ) url = serializers.CharField(source='get_absolute_url', read_only=True) diff --git a/src/backend/InvenTree/importer/mixins.py b/src/backend/InvenTree/importer/mixins.py index 96bc871734..23f7b6ce1b 100644 --- a/src/backend/InvenTree/importer/mixins.py +++ b/src/backend/InvenTree/importer/mixins.py @@ -7,7 +7,6 @@ import tablib from rest_framework import fields, serializers from taggit.serializers import TagListSerializerField -import importer.operations from InvenTree.helpers import DownloadFile, GetExportFormats, current_date @@ -83,7 +82,7 @@ class DataImportSerializerMixin: continue # Skip tags fields - # TODO: Implement tag field support + # TODO: Implement tag field import support if issubclass(field.__class__, TagListSerializerField): continue @@ -93,10 +92,17 @@ class DataImportSerializerMixin: class DataExportSerializerMixin: - """Mixin class for adding data export functionality to a DRF serializer.""" + """Mixin class for adding data export functionality to a DRF serializer. + + Attributes: + export_only_fields: List of field names which are only used during data export + export_exclude_fields: List of field names which are excluded during data export + export_child_fields: List of child fields which are exported (using dot notation) + """ export_only_fields = [] export_exclude_fields = [] + export_child_fields = [] def get_export_only_fields(self, **kwargs) -> list: """Return the list of field names which are only used during data export.""" @@ -142,20 +148,41 @@ class DataExportSerializerMixin: for name, field in self.fields.items(): # Skip write-only fields - if getattr(field, 'write_only', False): + if getattr(field, 'write_only', False) or name in write_only_fields: continue - if name in write_only_fields: + # Skip tags fields + # TODO: Implement tag field export support + if issubclass(field.__class__, TagListSerializerField): continue + # Top-level serializer fields can be exported with dot notation # Skip fields which are themselves serializers if issubclass(field.__class__, serializers.Serializer): + fields.update(self.get_child_fields(name, field)) continue fields[name] = field return fields + def get_child_fields(self, field_name: str, field) -> dict: + """Return a dictionary of child fields for a given field. + + Only child fields which match the 'export_child_fields' list will be returned. + """ + child_fields = {} + + if sub_fields := getattr(field, 'fields', None): + for sub_name, sub_field in sub_fields.items(): + name = f'{field_name}.{sub_name}' + + if name in self.export_child_fields: + sub_field.parent_field = field + child_fields[name] = sub_field + + return child_fields + def get_exported_filename(self, export_format) -> str: """Return the filename for the exported data file. @@ -177,6 +204,33 @@ class DataExportSerializerMixin: """Optional method to arrange the export headers.""" return headers + def get_nested_value(self, row: dict, key: str) -> any: + """Get a nested value from a dictionary. + + This method allows for dot notation to access nested fields. + + Arguments: + row: The dictionary to extract the value from + key: The key to extract + + Returns: + any: The extracted value + """ + keys = key.split('.') + + value = row + + for key in keys: + if not value: + break + + if not key: + continue + + value = value.get(key, None) + + return value + def process_row(self, row): """Optional method to process a row before exporting it.""" return row @@ -203,13 +257,18 @@ class DataExportSerializerMixin: for field_name, field in fields.items(): field = fields[field_name] - headers.append(importer.operations.get_field_label(field) or field_name) + label = getattr(field, 'label', field_name) + + if parent := getattr(field, 'parent_field', None): + label = f'{parent.label}.{label}' + + headers.append(label) dataset = tablib.Dataset(headers=headers) for row in data: row = self.process_row(row) - dataset.append([row.get(field, None) for field in field_names]) + dataset.append([self.get_nested_value(row, f) for f in field_names]) return dataset.export(file_format) diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index b7b53a293a..b3340d0ba0 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -73,7 +73,7 @@ def annotate_on_order_quantity(reference: str = ''): Note that in addition to the 'quantity' on order, we must also take into account 'pack_quantity'. """ - # Filter only 'active' purhase orders + # Filter only 'active' purchase orders # Filter only line with outstanding quantity order_filter = Q( order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index a19f797d39..bc0ae715ad 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1556,7 +1556,11 @@ class BomItemSerializer( import_exclude_fields = ['validated', 'substitutes'] - export_only_fields = ['sub_part_name', 'sub_part_ipn', 'sub_part_description'] + export_child_fields = [ + 'sub_part_detail.name', + 'sub_part_detail.IPN', + 'sub_part_detail.description', + ] class Meta: """Metaclass defining serializer fields.""" @@ -1565,10 +1569,6 @@ class BomItemSerializer( fields = [ 'part', 'sub_part', - # Extra fields only for export - 'sub_part_name', - 'sub_part_ipn', - 'sub_part_description', 'reference', 'quantity', 'overage', @@ -1646,17 +1646,8 @@ class BomItemSerializer( substitutes = BomItemSubstituteSerializer(many=True, read_only=True) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - - # Extra fields only for export - sub_part_name = serializers.CharField( - source='sub_part.name', read_only=True, label=_('Component Name') - ) - sub_part_ipn = serializers.CharField( - source='sub_part.IPN', read_only=True, label=_('Component IPN') - ) - sub_part_description = serializers.CharField( - source='sub_part.description', read_only=True, label=_('Component Description') + part_detail = PartBriefSerializer( + source='part', label=_('Assembly'), many=False, read_only=True ) sub_part = serializers.PrimaryKeyRelatedField( @@ -1665,7 +1656,9 @@ class BomItemSerializer( help_text=_('Select the component part'), ) - sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) + sub_part_detail = PartBriefSerializer( + source='sub_part', label=_('Component'), many=False, read_only=True + ) on_order = serializers.FloatField(label=_('On Order'), read_only=True) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index ab8bcbe0bb..d35d404bac 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -347,16 +347,23 @@ class StockLocationFilter(rest_filters.FilterSet): return queryset -class StockLocationList(DataExportViewMixin, ListCreateAPI): - """API endpoint for list view of StockLocation objects. +class StockLocationMixin: + """Mixin class for StockLocation API endpoints.""" - - GET: Return list of StockLocation objects - - POST: Create a new StockLocation - """ - - queryset = StockLocation.objects.all().prefetch_related('tags') + queryset = StockLocation.objects.all() serializer_class = StockSerializers.LocationSerializer - filterset_class = StockLocationFilter + + def get_serializer(self, *args, **kwargs): + """Set context before returning serializer.""" + try: + params = self.request.query_params + kwargs['path_detail'] = str2bool(params.get('path_detail', False)) + except AttributeError: # pragma: no cover + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) def get_queryset(self, *args, **kwargs): """Return annotated queryset for the StockLocationList endpoint.""" @@ -364,6 +371,15 @@ class StockLocationList(DataExportViewMixin, ListCreateAPI): queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) return queryset + +class StockLocationList(DataExportViewMixin, StockLocationMixin, ListCreateAPI): + """API endpoint for list view of StockLocation objects. + + - GET: Return list of StockLocation objects + - POST: Create a new StockLocation + """ + + filterset_class = StockLocationFilter filter_backends = SEARCH_ORDER_FILTER search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug'] @@ -373,6 +389,25 @@ class StockLocationList(DataExportViewMixin, ListCreateAPI): ordering = ['tree_id', 'lft', 'name'] +class StockLocationDetail(StockLocationMixin, CustomRetrieveUpdateDestroyAPI): + """API endpoint for detail view of StockLocation object.""" + + def destroy(self, request, *args, **kwargs): + """Delete a Stock location instance via the API.""" + delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1' + delete_sub_locations = str(request.data.get('delete_sub_locations', 0)) == '1' + + return super().destroy( + request, + *args, + **dict( + kwargs, + delete_sub_locations=delete_sub_locations, + delete_stock_items=delete_stock_items, + ), + ) + + class StockLocationTree(ListAPI): """API endpoint for accessing a list of StockLocation objects, ready for rendering as a tree.""" @@ -1471,52 +1506,6 @@ class StockTrackingList(DataExportViewMixin, ListAPI): search_fields = ['title', 'notes'] -class LocationDetail(CustomRetrieveUpdateDestroyAPI): - """API endpoint for detail view of StockLocation object. - - - GET: Return a single StockLocation object - - PATCH: Update a StockLocation object - - DELETE: Remove a StockLocation object - """ - - queryset = StockLocation.objects.all() - serializer_class = StockSerializers.LocationSerializer - - def get_serializer(self, *args, **kwargs): - """Add extra context to serializer based on provided query parameters.""" - try: - params = self.request.query_params - - kwargs['path_detail'] = str2bool(params.get('path_detail', False)) - except AttributeError: - pass - - kwargs['context'] = self.get_serializer_context() - - return self.serializer_class(*args, **kwargs) - - def get_queryset(self, *args, **kwargs): - """Return annotated queryset for the StockLocationList endpoint.""" - queryset = super().get_queryset(*args, **kwargs) - queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) - return queryset - - def destroy(self, request, *args, **kwargs): - """Delete a Stock location instance via the API.""" - delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1' - delete_sub_locations = str(request.data.get('delete_sub_locations', 0)) == '1' - - return super().destroy( - request, - *args, - **dict( - kwargs, - delete_sub_locations=delete_sub_locations, - delete_stock_items=delete_stock_items, - ), - ) - - stock_api_urls = [ path( 'location/', @@ -1532,7 +1521,7 @@ stock_api_urls = [ {'model': StockLocation}, name='api-location-metadata', ), - path('', LocationDetail.as_view(), name='api-location-detail'), + path('', StockLocationDetail.as_view(), name='api-location-detail'), ]), ), path('', StockLocationList.as_view(), name='api-location-list'), diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index e8deab1bd8..63c8a54b42 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -342,9 +342,20 @@ class StockItemSerializer( export_exclude_fields = ['tags', 'tracking_items'] - export_only_fields = ['part_pricing_min', 'part_pricing_max'] + export_child_fields = [ + 'part_detail.name', + 'part_detail.description', + 'part_detail.IPN', + 'part_detail.revision', + 'part_detail.pricing_min', + 'part_detail.pricing_max', + 'location_detail.name', + 'location_detail.pathstring', + 'supplier_part_detail.SKU', + 'supplier_part_detail.MPN', + ] - import_exclude_fields = ['use_pack_size'] + import_exclude_fields = ['use_pack_size', 'location_path'] class Meta: """Metaclass options.""" @@ -357,8 +368,6 @@ class StockItemSerializer( 'serial', 'batch', 'location', - 'location_name', - 'location_path', 'belongs_to', 'build', 'consumed_by', @@ -394,6 +403,7 @@ class StockItemSerializer( 'expired', 'installed_items', 'child_items', + 'location_path', 'stale', 'tracking_items', 'tags', @@ -401,9 +411,6 @@ class StockItemSerializer( 'supplier_part_detail', 'part_detail', 'location_detail', - # Export only fields - 'part_pricing_min', - 'part_pricing_max', ] """ @@ -426,11 +433,12 @@ class StockItemSerializer( def __init__(self, *args, **kwargs): """Add detail fields.""" part_detail = kwargs.pop('part_detail', True) - location_detail = kwargs.pop('location_detail', False) - supplier_part_detail = kwargs.pop('supplier_part_detail', False) - tests = kwargs.pop('tests', False) + location_detail = kwargs.pop('location_detail', True) + supplier_part_detail = kwargs.pop('supplier_part_detail', True) path_detail = kwargs.pop('path_detail', False) + tests = kwargs.pop('tests', False) + super().__init__(*args, **kwargs) if not part_detail: @@ -463,10 +471,6 @@ class StockItemSerializer( help_text=_('Parent stock item'), ) - location_name = serializers.CharField( - source='location.name', read_only=True, label=_('Location Name') - ) - location_path = serializers.ListField( child=serializers.DictField(), source='location.get_path', read_only=True ) @@ -504,7 +508,9 @@ class StockItemSerializer( """Add some extra annotations to the queryset, performing database queries as efficiently as possible.""" queryset = queryset.prefetch_related( 'location', + 'allocations', 'sales_order', + 'sales_order_allocations', 'purchase_order', Prefetch( 'part', @@ -516,12 +522,17 @@ class StockItemSerializer( ), 'parent', 'part__category', + 'part__supplier_parts', + 'part__supplier_parts__purchase_order_line_items', 'part__pricing_data', + 'part__tags', 'supplier_part', 'supplier_part__part', 'supplier_part__supplier', 'supplier_part__manufacturer_part', 'supplier_part__manufacturer_part__manufacturer', + 'supplier_part__manufacturer_part__tags', + 'supplier_part__purchase_order_line_items', 'supplier_part__tags', 'test_results', 'customer', @@ -593,7 +604,9 @@ class StockItemSerializer( # Optional detail fields, which can be appended via query parameters supplier_part_detail = company_serializers.SupplierPartSerializer( + label=_('Supplier Part'), source='supplier_part', + brief=True, supplier_detail=False, manufacturer_detail=False, part_detail=False, @@ -602,11 +615,11 @@ class StockItemSerializer( ) part_detail = part_serializers.PartBriefSerializer( - source='part', many=False, read_only=True + label=_('Part'), source='part', many=False, read_only=True ) location_detail = LocationBriefSerializer( - source='location', many=False, read_only=True + label=_('Location'), source='location', many=False, read_only=True ) tests = StockItemTestResultSerializer( @@ -646,24 +659,13 @@ class StockItemSerializer( purchase_order_reference = serializers.CharField( source='purchase_order.reference', read_only=True ) + sales_order_reference = serializers.CharField( source='sales_order.reference', read_only=True ) tags = TagListSerializerField(required=False) - part_pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( - source='part.pricing_data.overall_min', - read_only=True, - label=_('Minimum Pricing'), - ) - - part_pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( - source='part.pricing_data.overall_max', - read_only=True, - label=_('Maximum Pricing'), - ) - class SerializeStockItemSerializer(serializers.Serializer): """A DRF serializer for "serializing" a StockItem. @@ -1185,6 +1187,9 @@ class LocationSerializer( def annotate_queryset(queryset): """Annotate extra information to the queryset.""" # Annotate the number of stock items which exist in this category (including subcategories) + + queryset = queryset.prefetch_related('tags') + queryset = queryset.annotate( items=stock.filters.annotate_location_items(), sublocations=stock.filters.annotate_sub_locations(), diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 9be1e753ae..68de554d11 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -871,10 +871,15 @@ class StockItemListTest(StockAPITestCase): 'Part', 'Customer', 'Stock Location', - 'Location Name', 'Parent Item', 'Quantity', 'Status', + 'Part.Name', + 'Part.Description', + 'Location.Name', + 'Location.Path', + 'Supplier Part.SKU', + 'Supplier Part.MPN', ] for h in headers: @@ -886,12 +891,35 @@ class StockItemListTest(StockAPITestCase): self.assertNotIn(h, dataset.headers) # Now, add a filter to the results - dataset = self.export_data({'location': 1}) + dataset = self.export_data({'location': 1, 'cascade': True}) self.assertEqual(len(dataset), 9) - dataset = self.export_data({'part': 25}) + # Read out the data + idx_id = dataset.headers.index('ID') + idx_loc = dataset.headers.index('Stock Location') + idx_loc_name = dataset.headers.index('Location.Name') + idx_part_name = dataset.headers.index('Part.Name') + for row in dataset: + item_id = int(row[idx_id]) + item = StockItem.objects.get(pk=item_id) + + loc_id = int(row[idx_loc]) + + # Location should match ID + self.assertEqual(int(loc_id), item.location.pk) + + # Location name should match + loc_name = row[idx_loc_name] + self.assertEqual(loc_name, item.location.name) + + # Part name should match + part_name = row[idx_part_name] + self.assertEqual(part_name, item.part.name) + + # Export stock items with a specific part + dataset = self.export_data({'part': 25}) self.assertEqual(len(dataset), 17) def test_filter_by_allocated(self): diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index f5c9c51f0d..679673ddbe 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -64,7 +64,8 @@ export default function SupplierPartDetail() { hasPrimaryKey: true, params: { part_detail: true, - supplier_detail: true + supplier_detail: true, + manufacturer_detail: true } });