2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[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
This commit is contained in:
Oliver 2025-01-27 14:03:40 +11:00 committed by GitHub
parent 5968f5670f
commit ddcb7980ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 285 additions and 244 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,8 @@ export default function SupplierPartDetail() {
hasPrimaryKey: true,
params: {
part_detail: true,
supplier_detail: true
supplier_detail: true,
manufacturer_detail: true
}
});