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 information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v304 - 2025-01-22 : https://github.com/inventree/InvenTree/pull/8940
- Adds "category" filter to build list API - 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.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import QuerySet
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@ -820,26 +821,20 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
""" """
raise NotImplementedError(f'items() method not implemented for {type(self)}') raise NotImplementedError(f'items() method not implemented for {type(self)}')
def getUniqueParents(self): def getUniqueParents(self) -> QuerySet:
"""Return a flat set of all parent items that exist above this node. """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
"""
return self.get_ancestors() return self.get_ancestors()
def getUniqueChildren(self, include_self=True): def getUniqueChildren(self, include_self=True) -> QuerySet:
"""Return a flat set of all child items that exist under this node. """Return a flat set of all child items that exist under this node."""
If any child items are repeated, the repetitions are omitted.
"""
return self.get_descendants(include_self=include_self) return self.get_descendants(include_self=include_self)
@property @property
def has_children(self): def has_children(self) -> bool:
"""True if there are any children under this item.""" """True if there are any children under this item."""
return self.getUniqueChildren(include_self=False).count() > 0 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. """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. Setting the parent of an item to its own child results in recursion.
@ -860,7 +855,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
return acceptable return acceptable
@property @property
def parentpath(self): def parentpath(self) -> list:
"""Get the parent path of this category. """Get the parent path of this category.
Returns: Returns:
@ -869,7 +864,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
return list(self.get_ancestors()) return list(self.get_ancestors())
@property @property
def path(self): def path(self) -> list:
"""Get the complete part of this category. """Get the complete part of this category.
e.g. ["Top", "Second", "Third", "This"] e.g. ["Top", "Second", "Third", "This"]
@ -879,7 +874,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
""" """
return [*self.parentpath, self] return [*self.parentpath, self]
def get_path(self): def get_path(self) -> list:
"""Return a list of element in the item tree. """Return a list of element in the item tree.
Contains the full path to this item, with each entry containing the following data: 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): class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializes a BuildItem object, which is an allocation of a stock item against a build order.""" """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_child_fields = [
export_only_fields = [ 'build_detail.reference',
'bom_part_id', 'location_detail.name',
'bom_part_name', 'part_detail.name',
'build_reference', 'part_detail.description',
'sku', 'part_detail.IPN',
'mpn', 'stock_item_detail.batch',
'location_name', 'stock_item_detail.packaging',
'part_id', 'stock_item_detail.part',
'part_name', 'stock_item_detail.quantity',
'part_ipn', 'stock_item_detail.serial',
'part_description', 'supplier_part_detail.SKU',
'available_quantity', 'supplier_part_detail.MPN',
'item_batch_code',
'item_serial',
'item_packaging',
] ]
# These fields are only used for data export
export_only_fields = ['bom_part_id', 'bom_part_name']
class Meta: class Meta:
"""Serializer metaclass.""" """Serializer metaclass."""
@ -1192,18 +1192,6 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'bom_reference', 'bom_reference',
'bom_part_id', 'bom_part_id',
'bom_part_name', '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): def __init__(self, *args, **kwargs):
@ -1211,7 +1199,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
part_detail = kwargs.pop('part_detail', True) part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', True) location_detail = kwargs.pop('location_detail', True)
stock_detail = kwargs.pop('stock_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) super().__init__(*args, **kwargs)
@ -1228,44 +1216,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
self.fields.pop('build_detail', None) self.fields.pop('build_detail', None)
# Export-only fields # 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( bom_reference = serializers.CharField(
source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True 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 Item Part ID (it may be different to the allocated part)
bom_part_id = serializers.PrimaryKeyRelatedField( bom_part_id = serializers.PrimaryKeyRelatedField(
@ -1274,19 +1227,13 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
many=False, many=False,
read_only=True, read_only=True,
) )
bom_part_name = serializers.CharField( bom_part_name = serializers.CharField(
source='build_line.bom_item.sub_part.name', source='build_line.bom_item.sub_part.name',
label=_('BOM Part Name'), label=_('BOM Part Name'),
read_only=True, 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 # Annotated fields
build = serializers.PrimaryKeyRelatedField( build = serializers.PrimaryKeyRelatedField(
source='build_line.build', many=False, read_only=True source='build_line.build', many=False, read_only=True
@ -1294,26 +1241,38 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
# Extra (optional) detail fields # Extra (optional) detail fields
part_detail = part_serializers.PartBriefSerializer( 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( 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( location_detail = LocationBriefSerializer(
source='stock_item.location', read_only=True label=_('Location'), source='stock_item.location', read_only=True
) )
build_detail = BuildSerializer( 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( 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')) quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
available_quantity = InvenTreeDecimalField(
source='stock_item.quantity', read_only=True, label=_('Available Quantity')
)
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
@ -1321,7 +1280,16 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
export_exclude_fields = ['allocations'] 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: class Meta:
"""Serializer metaclass.""" """Serializer metaclass."""
@ -1332,6 +1300,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build', 'build',
'bom_item', 'bom_item',
'quantity', 'quantity',
'part',
# Build detail fields # Build detail fields
'build_reference', 'build_reference',
# BOM item detail fields # BOM item detail fields
@ -1342,11 +1311,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'trackable', 'trackable',
'inherited', 'inherited',
'allow_variants', 'allow_variants',
# Part detail fields
'part',
'part_name',
'part_IPN',
'part_category_id',
# Annotated fields # Annotated fields
'allocated', 'allocated',
'in_production', 'in_production',
@ -1358,7 +1322,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
# Related fields # Related fields
'allocations', 'allocations',
# Extra fields only for data export # Extra fields only for data export
'part_description',
'part_category_name', 'part_category_name',
# Extra detail (related field) serializers # Extra detail (related field) serializers
'bom_item_detail', 'bom_item_detail',
@ -1371,7 +1334,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included.""" """Determine which extra details fields should be included."""
part_detail = kwargs.pop('part_detail', True) 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) super().__init__(*args, **kwargs)
@ -1390,21 +1353,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
part = serializers.PrimaryKeyRelatedField( part = serializers.PrimaryKeyRelatedField(
source='bom_item.sub_part', label=_('Part'), many=False, read_only=True 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( part_category_name = serializers.CharField(
source='bom_item.sub_part.category.name', source='bom_item.sub_part.category.name',
label=_('Part Category Name'), label=_('Part Category Name'),
@ -1442,6 +1391,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
# Foreign key fields # Foreign key fields
bom_item_detail = part_serializers.BomItemSerializer( bom_item_detail = part_serializers.BomItemSerializer(
label=_('BOM Item'),
source='bom_item', source='bom_item',
many=False, many=False,
read_only=True, read_only=True,
@ -1452,10 +1402,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
) )
part_detail = part_serializers.PartBriefSerializer( 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( 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 # Annotated (calculated) fields

View File

@ -315,6 +315,16 @@ class SupplierPartSerializer(
): ):
"""Serializer for SupplierPart object.""" """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: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -327,12 +337,10 @@ class SupplierPartSerializer(
'on_order', 'on_order',
'link', 'link',
'active', 'active',
'manufacturer',
'manufacturer_detail', 'manufacturer_detail',
'manufacturer_part', 'manufacturer_part',
'manufacturer_part_detail', 'manufacturer_part_detail',
'MPN', 'MPN',
'name',
'note', 'note',
'pk', 'pk',
'barcode_hash', 'barcode_hash',
@ -393,6 +401,7 @@ class SupplierPartSerializer(
if brief: if brief:
self.fields.pop('tags') self.fields.pop('tags')
self.fields.pop('available') self.fields.pop('available')
self.fields.pop('on_order')
self.fields.pop('availability_updated') self.fields.pop('availability_updated')
# Annotated field showing total in-stock quantity # Annotated field showing total in-stock quantity
@ -405,32 +414,36 @@ class SupplierPartSerializer(
pack_quantity_native = serializers.FloatField(read_only=True) pack_quantity_native = serializers.FloatField(read_only=True)
part_detail = part_serializers.PartBriefSerializer( part_detail = part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True label=_('Part'), source='part', many=False, read_only=True
) )
supplier_detail = CompanyBriefSerializer( supplier_detail = CompanyBriefSerializer(
source='supplier', many=False, read_only=True label=_('Supplier'), source='supplier', many=False, read_only=True
) )
manufacturer_detail = CompanyBriefSerializer( 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) pretty_name = serializers.CharField(read_only=True)
supplier = serializers.PrimaryKeyRelatedField( 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( 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) 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 rest_framework import fields, serializers
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import importer.operations
from InvenTree.helpers import DownloadFile, GetExportFormats, current_date from InvenTree.helpers import DownloadFile, GetExportFormats, current_date
@ -83,7 +82,7 @@ class DataImportSerializerMixin:
continue continue
# Skip tags fields # Skip tags fields
# TODO: Implement tag field support # TODO: Implement tag field import support
if issubclass(field.__class__, TagListSerializerField): if issubclass(field.__class__, TagListSerializerField):
continue continue
@ -93,10 +92,17 @@ class DataImportSerializerMixin:
class DataExportSerializerMixin: 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_only_fields = []
export_exclude_fields = [] export_exclude_fields = []
export_child_fields = []
def get_export_only_fields(self, **kwargs) -> list: def get_export_only_fields(self, **kwargs) -> list:
"""Return the list of field names which are only used during data export.""" """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(): for name, field in self.fields.items():
# Skip write-only fields # Skip write-only fields
if getattr(field, 'write_only', False): if getattr(field, 'write_only', False) or name in write_only_fields:
continue continue
if name in write_only_fields: # Skip tags fields
# TODO: Implement tag field export support
if issubclass(field.__class__, TagListSerializerField):
continue continue
# Top-level serializer fields can be exported with dot notation
# Skip fields which are themselves serializers # Skip fields which are themselves serializers
if issubclass(field.__class__, serializers.Serializer): if issubclass(field.__class__, serializers.Serializer):
fields.update(self.get_child_fields(name, field))
continue continue
fields[name] = field fields[name] = field
return fields 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: def get_exported_filename(self, export_format) -> str:
"""Return the filename for the exported data file. """Return the filename for the exported data file.
@ -177,6 +204,33 @@ class DataExportSerializerMixin:
"""Optional method to arrange the export headers.""" """Optional method to arrange the export headers."""
return 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): def process_row(self, row):
"""Optional method to process a row before exporting it.""" """Optional method to process a row before exporting it."""
return row return row
@ -203,13 +257,18 @@ class DataExportSerializerMixin:
for field_name, field in fields.items(): for field_name, field in fields.items():
field = fields[field_name] 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) dataset = tablib.Dataset(headers=headers)
for row in data: for row in data:
row = self.process_row(row) 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) 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'. 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 # Filter only line with outstanding quantity
order_filter = Q( order_filter = Q(
order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received')

View File

@ -1556,7 +1556,11 @@ class BomItemSerializer(
import_exclude_fields = ['validated', 'substitutes'] 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: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
@ -1565,10 +1569,6 @@ class BomItemSerializer(
fields = [ fields = [
'part', 'part',
'sub_part', 'sub_part',
# Extra fields only for export
'sub_part_name',
'sub_part_ipn',
'sub_part_description',
'reference', 'reference',
'quantity', 'quantity',
'overage', 'overage',
@ -1646,17 +1646,8 @@ class BomItemSerializer(
substitutes = BomItemSubstituteSerializer(many=True, read_only=True) substitutes = BomItemSubstituteSerializer(many=True, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(
source='part', label=_('Assembly'), 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')
) )
sub_part = serializers.PrimaryKeyRelatedField( sub_part = serializers.PrimaryKeyRelatedField(
@ -1665,7 +1656,9 @@ class BomItemSerializer(
help_text=_('Select the component part'), 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) on_order = serializers.FloatField(label=_('On Order'), read_only=True)

View File

@ -347,16 +347,23 @@ class StockLocationFilter(rest_filters.FilterSet):
return queryset return queryset
class StockLocationList(DataExportViewMixin, ListCreateAPI): class StockLocationMixin:
"""API endpoint for list view of StockLocation objects. """Mixin class for StockLocation API endpoints."""
- GET: Return list of StockLocation objects queryset = StockLocation.objects.all()
- POST: Create a new StockLocation
"""
queryset = StockLocation.objects.all().prefetch_related('tags')
serializer_class = StockSerializers.LocationSerializer 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): def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationList endpoint.""" """Return annotated queryset for the StockLocationList endpoint."""
@ -364,6 +371,15 @@ class StockLocationList(DataExportViewMixin, ListCreateAPI):
queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset)
return 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 filter_backends = SEARCH_ORDER_FILTER
search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug'] search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug']
@ -373,6 +389,25 @@ class StockLocationList(DataExportViewMixin, ListCreateAPI):
ordering = ['tree_id', 'lft', 'name'] 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): class StockLocationTree(ListAPI):
"""API endpoint for accessing a list of StockLocation objects, ready for rendering as a tree.""" """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'] 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 = [ stock_api_urls = [
path( path(
'location/', 'location/',
@ -1532,7 +1521,7 @@ stock_api_urls = [
{'model': StockLocation}, {'model': StockLocation},
name='api-location-metadata', 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'), path('', StockLocationList.as_view(), name='api-location-list'),

View File

@ -342,9 +342,20 @@ class StockItemSerializer(
export_exclude_fields = ['tags', 'tracking_items'] 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: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -357,8 +368,6 @@ class StockItemSerializer(
'serial', 'serial',
'batch', 'batch',
'location', 'location',
'location_name',
'location_path',
'belongs_to', 'belongs_to',
'build', 'build',
'consumed_by', 'consumed_by',
@ -394,6 +403,7 @@ class StockItemSerializer(
'expired', 'expired',
'installed_items', 'installed_items',
'child_items', 'child_items',
'location_path',
'stale', 'stale',
'tracking_items', 'tracking_items',
'tags', 'tags',
@ -401,9 +411,6 @@ class StockItemSerializer(
'supplier_part_detail', 'supplier_part_detail',
'part_detail', 'part_detail',
'location_detail', 'location_detail',
# Export only fields
'part_pricing_min',
'part_pricing_max',
] ]
""" """
@ -426,11 +433,12 @@ class StockItemSerializer(
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Add detail fields.""" """Add detail fields."""
part_detail = kwargs.pop('part_detail', True) part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', False) location_detail = kwargs.pop('location_detail', True)
supplier_part_detail = kwargs.pop('supplier_part_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', True)
tests = kwargs.pop('tests', False)
path_detail = kwargs.pop('path_detail', False) path_detail = kwargs.pop('path_detail', False)
tests = kwargs.pop('tests', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not part_detail: if not part_detail:
@ -463,10 +471,6 @@ class StockItemSerializer(
help_text=_('Parent stock item'), help_text=_('Parent stock item'),
) )
location_name = serializers.CharField(
source='location.name', read_only=True, label=_('Location Name')
)
location_path = serializers.ListField( location_path = serializers.ListField(
child=serializers.DictField(), source='location.get_path', read_only=True 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.""" """Add some extra annotations to the queryset, performing database queries as efficiently as possible."""
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'location', 'location',
'allocations',
'sales_order', 'sales_order',
'sales_order_allocations',
'purchase_order', 'purchase_order',
Prefetch( Prefetch(
'part', 'part',
@ -516,12 +522,17 @@ class StockItemSerializer(
), ),
'parent', 'parent',
'part__category', 'part__category',
'part__supplier_parts',
'part__supplier_parts__purchase_order_line_items',
'part__pricing_data', 'part__pricing_data',
'part__tags',
'supplier_part', 'supplier_part',
'supplier_part__part', 'supplier_part__part',
'supplier_part__supplier', 'supplier_part__supplier',
'supplier_part__manufacturer_part', 'supplier_part__manufacturer_part',
'supplier_part__manufacturer_part__manufacturer', 'supplier_part__manufacturer_part__manufacturer',
'supplier_part__manufacturer_part__tags',
'supplier_part__purchase_order_line_items',
'supplier_part__tags', 'supplier_part__tags',
'test_results', 'test_results',
'customer', 'customer',
@ -593,7 +604,9 @@ class StockItemSerializer(
# Optional detail fields, which can be appended via query parameters # Optional detail fields, which can be appended via query parameters
supplier_part_detail = company_serializers.SupplierPartSerializer( supplier_part_detail = company_serializers.SupplierPartSerializer(
label=_('Supplier Part'),
source='supplier_part', source='supplier_part',
brief=True,
supplier_detail=False, supplier_detail=False,
manufacturer_detail=False, manufacturer_detail=False,
part_detail=False, part_detail=False,
@ -602,11 +615,11 @@ class StockItemSerializer(
) )
part_detail = part_serializers.PartBriefSerializer( part_detail = part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True label=_('Part'), source='part', many=False, read_only=True
) )
location_detail = LocationBriefSerializer( location_detail = LocationBriefSerializer(
source='location', many=False, read_only=True label=_('Location'), source='location', many=False, read_only=True
) )
tests = StockItemTestResultSerializer( tests = StockItemTestResultSerializer(
@ -646,24 +659,13 @@ class StockItemSerializer(
purchase_order_reference = serializers.CharField( purchase_order_reference = serializers.CharField(
source='purchase_order.reference', read_only=True source='purchase_order.reference', read_only=True
) )
sales_order_reference = serializers.CharField( sales_order_reference = serializers.CharField(
source='sales_order.reference', read_only=True source='sales_order.reference', read_only=True
) )
tags = TagListSerializerField(required=False) 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): class SerializeStockItemSerializer(serializers.Serializer):
"""A DRF serializer for "serializing" a StockItem. """A DRF serializer for "serializing" a StockItem.
@ -1185,6 +1187,9 @@ class LocationSerializer(
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate extra information to the queryset.""" """Annotate extra information to the queryset."""
# Annotate the number of stock items which exist in this category (including subcategories) # Annotate the number of stock items which exist in this category (including subcategories)
queryset = queryset.prefetch_related('tags')
queryset = queryset.annotate( queryset = queryset.annotate(
items=stock.filters.annotate_location_items(), items=stock.filters.annotate_location_items(),
sublocations=stock.filters.annotate_sub_locations(), sublocations=stock.filters.annotate_sub_locations(),

View File

@ -871,10 +871,15 @@ class StockItemListTest(StockAPITestCase):
'Part', 'Part',
'Customer', 'Customer',
'Stock Location', 'Stock Location',
'Location Name',
'Parent Item', 'Parent Item',
'Quantity', 'Quantity',
'Status', 'Status',
'Part.Name',
'Part.Description',
'Location.Name',
'Location.Path',
'Supplier Part.SKU',
'Supplier Part.MPN',
] ]
for h in headers: for h in headers:
@ -886,12 +891,35 @@ class StockItemListTest(StockAPITestCase):
self.assertNotIn(h, dataset.headers) self.assertNotIn(h, dataset.headers)
# Now, add a filter to the results # 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) 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) self.assertEqual(len(dataset), 17)
def test_filter_by_allocated(self): def test_filter_by_allocated(self):

View File

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