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

[Bug] mport fix (#9008)

* Better handling of request object in serializers

* Pass request object through

- Required to extract user information

* Strip column header during import

- Prevent mismatch due to whitespace

* Fix for "minimum stock" field

* Fix for part serializer

* Extract default values on import

* Remove outdated migration message

* Bump API version
This commit is contained in:
Oliver 2025-02-02 07:57:06 +11:00 committed by GitHub
parent eba004d835
commit c077e2b605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 79 additions and 54 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 308 INVENTREE_API_VERSION = 309
"""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 = """
v309 - 2025-02-02 : https://github.com/inventree/InvenTree/pull/9008
- Bug fixes for the "Part" serializer
- Fixes for data import API endpoints
v308 - 2025-02-01 : https://github.com/inventree/InvenTree/pull/9003 v308 - 2025-02-01 : https://github.com/inventree/InvenTree/pull/9003
- Adds extra detail to the ReportOutput and LabelOutput API endpoints - Adds extra detail to the ReportOutput and LabelOutput API endpoints
- Allows ordering of output list endpoints - Allows ordering of output list endpoints

View File

@ -33,6 +33,9 @@ def log_error(path, error_name=None, error_info=None, error_data=None):
""" """
from error_report.models import Error from error_report.models import Error
if not path:
path = ''
kind, info, data = sys.exc_info() kind, info, data = sys.exc_info()
# Check if the error is on the ignore list # Check if the error is on the ignore list
@ -104,17 +107,19 @@ def exception_handler(exc, context):
else: else:
error_detail = _('Error details can be found in the admin panel') error_detail = _('Error details can be found in the admin panel')
request = context.get('request')
path = request.path if request else ''
response_data = { response_data = {
'error': type(exc).__name__, 'error': type(exc).__name__,
'error_class': str(type(exc)), 'error_class': str(type(exc)),
'detail': error_detail, 'detail': error_detail,
'path': context['request'].path, 'path': path,
'status_code': 500, 'status_code': 500,
} }
response = Response(response_data, status=500) response = Response(response_data, status=500)
log_error(path)
log_error(context['request'].path)
if response is not None: if response is not None:
# Convert errors returned under the label '__all__' to 'non_field_errors' # Convert errors returned under the label '__all__' to 'non_field_errors'

View File

@ -253,11 +253,12 @@ class BuildOutputSerializer(serializers.Serializer):
# The build output must have all tracked parts allocated # The build output must have all tracked parts allocated
if not build.is_output_fully_allocated(output): if not build.is_output_fully_allocated(output):
# Check if the user has specified that incomplete allocations are ok # Check if the user has specified that incomplete allocations are ok
accept_incomplete = InvenTree.helpers.str2bool( if request := self.context.get('request'):
self.context['request'].data.get( accept_incomplete = InvenTree.helpers.str2bool(
'accept_incomplete_allocation', False request.data.get('accept_incomplete_allocation', False)
) )
) else:
accept_incomplete = False
if not accept_incomplete: if not accept_incomplete:
raise ValidationError(_('This build output is not fully allocated')) raise ValidationError(_('This build output is not fully allocated'))
@ -439,6 +440,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
"""Generate the new build output(s).""" """Generate the new build output(s)."""
data = self.validated_data data = self.validated_data
request = self.context.get('request')
build = self.get_build() build = self.get_build()
build.create_build_output( build.create_build_output(
@ -447,7 +449,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
batch=data.get('batch_code', ''), batch=data.get('batch_code', ''),
location=data.get('location', None), location=data.get('location', None),
auto_allocate=data.get('auto_allocate', False), auto_allocate=data.get('auto_allocate', False),
user=self.context['request'].user, user=request.user if request else None,
) )
@ -531,7 +533,7 @@ class BuildOutputScrapSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to scrap the build outputs.""" """Save the serializer to scrap the build outputs."""
build = self.context['build'] build = self.context['build']
request = self.context['request'] request = self.context.get('request')
data = self.validated_data data = self.validated_data
outputs = data.get('outputs', []) outputs = data.get('outputs', [])
@ -544,7 +546,7 @@ class BuildOutputScrapSerializer(serializers.Serializer):
output, output,
quantity, quantity,
data.get('location', None), data.get('location', None),
user=request.user, user=request.user if request else None,
notes=data.get('notes', ''), notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False), discard_allocations=data.get('discard_allocations', False),
) )
@ -619,7 +621,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to complete the build outputs.""" """Save the serializer to complete the build outputs."""
build = self.context['build'] build = self.context['build']
request = self.context['request'] request = self.context.get('request')
data = self.validated_data data = self.validated_data
@ -642,7 +644,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
build.complete_build_output( build.complete_build_output(
output, output,
request.user, request.user if request else None,
location=location, location=location,
status=status, status=status,
notes=notes, notes=notes,
@ -715,12 +717,12 @@ class BuildCancelSerializer(serializers.Serializer):
def save(self): def save(self):
"""Cancel the specified build.""" """Cancel the specified build."""
build = self.context['build'] build = self.context['build']
request = self.context['request'] request = self.context.get('request')
data = self.validated_data data = self.validated_data
build.cancel_build( build.cancel_build(
request.user, request.user if request else None,
remove_allocated_stock=data.get('remove_allocated_stock', False), remove_allocated_stock=data.get('remove_allocated_stock', False),
remove_incomplete_outputs=data.get('remove_incomplete_outputs', False), remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
) )
@ -837,13 +839,13 @@ class BuildCompleteSerializer(serializers.Serializer):
def save(self): def save(self):
"""Complete the specified build output.""" """Complete the specified build output."""
request = self.context['request'] request = self.context.get('request')
build = self.context['build'] build = self.context['build']
data = self.validated_data data = self.validated_data
build.complete_build( build.complete_build(
request.user, request.user if request else None,
trim_allocated_stock=data.get( trim_allocated_stock=data.get(
'accept_overallocated', OverallocationChoice.REJECT 'accept_overallocated', OverallocationChoice.REJECT
) )

View File

@ -251,8 +251,8 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
target['link'] = obj.target_object.get_absolute_url() target['link'] = obj.target_object.get_absolute_url()
else: else:
# check if user is staff - link to admin # check if user is staff - link to admin
request = self.context['request'] request = self.context.get('request')
if request.user and request.user.is_staff: if request and request.user and request.user.is_staff:
meta = obj.target_object._meta meta = obj.target_object._meta
try: try:

View File

@ -145,6 +145,7 @@ class DataImportSession(models.Model):
- Extract column names from the data file - Extract column names from the data file
- Create a default mapping for each field in the serializer - Create a default mapping for each field in the serializer
- Find a default "backup" value for each field (if one exists)
""" """
# Extract list of column names from the file # Extract list of column names from the file
self.columns = importer.operations.extract_column_names(self.data_file) self.columns = importer.operations.extract_column_names(self.data_file)
@ -158,6 +159,7 @@ class DataImportSession(models.Model):
matched_columns = set() matched_columns = set()
self.field_defaults = self.field_defaults or {}
field_overrides = self.field_overrides or {} field_overrides = self.field_overrides or {}
# Create a default mapping for each available field in the database # Create a default mapping for each available field in the database
@ -167,6 +169,10 @@ class DataImportSession(models.Model):
if field in field_overrides: if field in field_overrides:
continue continue
# Extract a "default" value for the field, if one exists
if 'default' in field_def:
self.field_defaults[field] = field_def['default']
# Generate a list of possible column names for this field # Generate a list of possible column names for this field
field_options = [ field_options = [
field, field,
@ -558,7 +564,7 @@ class DataImportRow(models.Model):
if not available_fields: if not available_fields:
available_fields = self.session.available_fields() available_fields = self.session.available_fields()
overrride_values = self.override_values override_values = self.override_values
default_values = self.default_values default_values = self.default_values
data = {} data = {}
@ -566,8 +572,8 @@ class DataImportRow(models.Model):
# We have mapped column (file) to field (serializer) already # We have mapped column (file) to field (serializer) already
for field, col in field_mapping.items(): for field, col in field_mapping.items():
# Data override (force value and skip any further checks) # Data override (force value and skip any further checks)
if field in overrride_values: if field in override_values:
data[field] = overrride_values[field] data[field] = override_values[field]
continue continue
# Default value (if provided) # Default value (if provided)
@ -617,16 +623,19 @@ class DataImportRow(models.Model):
return data return data
def construct_serializer(self): def construct_serializer(self, request=None):
"""Construct a serializer object for this row.""" """Construct a serializer object for this row."""
if serializer_class := self.session.serializer_class: if serializer_class := self.session.serializer_class:
return serializer_class(data=self.serializer_data()) return serializer_class(
data=self.serializer_data(), context={'request': request}
)
def validate(self, commit=False) -> bool: def validate(self, commit=False, request=None) -> bool:
"""Validate the data in this row against the linked serializer. """Validate the data in this row against the linked serializer.
Arguments: Arguments:
commit: If True, the data is saved to the database (if validation passes) commit: If True, the data is saved to the database (if validation passes)
request: The request object (if available) for extracting user information
Returns: Returns:
True if the data is valid, False otherwise True if the data is valid, False otherwise
@ -638,7 +647,7 @@ class DataImportRow(models.Model):
# Row has already been completed # Row has already been completed
return True return True
serializer = self.construct_serializer() serializer = self.construct_serializer(request=request)
if not serializer: if not serializer:
self.errors = { self.errors = {
@ -660,12 +669,12 @@ class DataImportRow(models.Model):
try: try:
serializer.save() serializer.save()
self.complete = True self.complete = True
self.save()
self.session.check_complete() except ValueError as e: # Exception as e:
except Exception as e:
self.errors = {'non_field_errors': str(e)} self.errors = {'non_field_errors': str(e)}
result = False result = False
self.save()
self.session.check_complete()
return result return result

View File

@ -72,6 +72,8 @@ def extract_column_names(data_file) -> list:
headers = [] headers = []
for idx, header in enumerate(data.headers): for idx, header in enumerate(data.headers):
header = header.strip()
if header: if header:
headers.append(header) headers.append(header)
else: else:

View File

@ -207,8 +207,10 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
"""Complete the provided rows.""" """Complete the provided rows."""
rows = self.validated_data['rows'] rows = self.validated_data['rows']
request = self.context.get('request', None)
for row in rows: for row in rows:
row.validate(commit=True) row.validate(commit=True, request=request)
if session := self.context.get('session', None): if session := self.context.get('session', None):
session.check_complete() session.check_complete()

View File

@ -950,7 +950,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
"""Perform the actual database transaction to receive purchase order items.""" """Perform the actual database transaction to receive purchase order items."""
data = self.validated_data data = self.validated_data
request = self.context['request'] request = self.context.get('request')
order = self.context['order'] order = self.context['order']
items = data['items'] items = data['items']
@ -973,7 +973,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
item['line_item'], item['line_item'],
loc, loc,
item['quantity'], item['quantity'],
request.user, request.user if request else None,
status=item['status'], status=item['status'],
barcode=item.get('barcode', ''), barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''), batch_code=item.get('batch_code', ''),
@ -1434,8 +1434,8 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
data = self.validated_data data = self.validated_data
request = self.context['request'] request = self.context.get('request')
user = request.user user = request.user if request else None
# Extract shipping date (defaults to today's date) # Extract shipping date (defaults to today's date)
now = current_date() now = current_date()
@ -1572,10 +1572,10 @@ class SalesOrderCompleteSerializer(OrderAdjustSerializer):
def save(self): def save(self):
"""Save the serializer to complete the SalesOrder.""" """Save the serializer to complete the SalesOrder."""
request = self.context['request'] request = self.context.get('request')
data = self.validated_data data = self.validated_data
user = getattr(request, 'user', None) user = request.user if request else None
self.order.ship_order( self.order.ship_order(
user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)) user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False))
@ -2020,7 +2020,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
def save(self): def save(self):
"""Saving this serializer marks the returned items as received.""" """Saving this serializer marks the returned items as received."""
order = self.context['order'] order = self.context['order']
request = self.context['request'] request = self.context.get('request')
data = self.validated_data data = self.validated_data
items = data['items'] items = data['items']
@ -2033,7 +2033,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
order.receive_line_item( order.receive_line_item(
line_item, line_item,
location, location,
request.user, request.user if request else None,
note=data.get('note', ''), note=data.get('note', ''),
status=item.get('status', None), status=item.get('status', None),
) )

View File

@ -683,7 +683,7 @@ class PartSerializer(
Used when displaying all details of a single component. Used when displaying all details of a single component.
""" """
import_exclude_fields = ['duplicate'] import_exclude_fields = ['creation_date', 'creation_user', 'duplicate']
class Meta: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
@ -760,7 +760,7 @@ class PartSerializer(
'tags', 'tags',
] ]
read_only_fields = ['barcode_hash', 'creation_date'] read_only_fields = ['barcode_hash', 'creation_date', 'creation_user']
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@ -972,7 +972,9 @@ class PartSerializer(
category_default_location = serializers.IntegerField(read_only=True) category_default_location = serializers.IntegerField(read_only=True)
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock')) variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
minimum_stock = serializers.FloatField() minimum_stock = serializers.FloatField(
required=False, label=_('Minimum Stock'), default=0
)
image = InvenTree.serializers.InvenTreeImageSerializerField( image = InvenTree.serializers.InvenTreeImageSerializerField(
required=False, allow_null=True required=False, allow_null=True
@ -1062,8 +1064,8 @@ class PartSerializer(
instance = super().create(validated_data) instance = super().create(validated_data)
# Save user information # Save user information
if self.context['request']: if request := self.context.get('request'):
instance.creation_user = self.context['request'].user instance.creation_user = request.user
instance.save() instance.save()
# Copy data from original Part # Copy data from original Part
@ -1123,7 +1125,9 @@ class PartSerializer(
part=instance, quantity=quantity, location=location part=instance, quantity=quantity, location=location
) )
stockitem.save(user=self.context['request'].user) request = self.context.get('request', None)
user = request.user if request else None
stockitem.save(user=user)
# Create initial supplier information # Create initial supplier information
if initial_supplier: if initial_supplier:
@ -1222,9 +1226,8 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
data = self.validated_data data = self.validated_data
# Add in user information automatically # Add in user information automatically
request = self.context['request'] request = self.context.get('request')
data['user'] = request.user data['user'] = request.user if request else None
super().save() super().save()
@ -1979,7 +1982,7 @@ class BomImportUploadSerializer(InvenTree.serializers.DataFileUploadSerializer):
class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer): class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer):
"""Serializer class for exatracting BOM data from an uploaded file. """Serializer class for extracting BOM data from an uploaded file.
The parent class DataFileExtractSerializer does most of the heavy lifting here. The parent class DataFileExtractSerializer does most of the heavy lifting here.

View File

@ -46,9 +46,7 @@ def convert_legacy_labels(table_name, model_name, template_model):
try: try:
cursor.execute(query) cursor.execute(query)
except Exception: except Exception:
# Table likely does not exist # Table likely does not exist - database was created more recently
if not InvenTree.ready.isInTestMode():
print(f"\nLegacy label table {table_name} not found - skipping migration")
return 0 return 0
rows = cursor.fetchall() rows = cursor.fetchall()

View File

@ -764,8 +764,8 @@ class SerializeStockItemSerializer(serializers.Serializer):
def save(self): def save(self):
"""Serialize stock item.""" """Serialize stock item."""
item = self.context['item'] item = self.context['item']
request = self.context['request'] request = self.context.get('request')
user = request.user user = request.user if request else None
data = self.validated_data data = self.validated_data