mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +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:
parent
5968f5670f
commit
ddcb7980ff
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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(),
|
||||
|
@ -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):
|
||||
|
@ -64,7 +64,8 @@ export default function SupplierPartDetail() {
|
||||
hasPrimaryKey: true,
|
||||
params: {
|
||||
part_detail: true,
|
||||
supplier_detail: true
|
||||
supplier_detail: true,
|
||||
manufacturer_detail: true
|
||||
}
|
||||
});
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user