mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 20:45:44 +00:00
[PUI] Add BOM import tool (#7635)
* Add "field_overrides" field to DataImportSession model * Adjust logic for extracting field value * Add import drawer to BOM table * Enable download of BOM data * Improve support for hidden errors in forms * Improve form submission on front-end - Handle a mix of files and JSON fields - Stringify any objects * Update backend validation for data import session - Accept override values if provided - Ensure correct data format - Update fields for BomItem serializer * Add completion check for data import session * Improvements to importer drawer * Render column selection as a table * Add debouncing to text form fields - Significantly reduces rendering calls * Fix for TextField * Allow instance data to be updated manually * Allow specification of per-field default values when importing data * Improve rendering of import * Improve UI for data import drawer * Bump API version * Add callback after bulk delete * Update playwright test * Fix for editRow function
This commit is contained in:
@ -1,12 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 221
|
||||
INVENTREE_API_VERSION = 222
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v222 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7635
|
||||
- Adjust the BomItem API endpoint to improve data import process
|
||||
|
||||
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
|
||||
- Adds missing fields from StockItemBriefSerializer
|
||||
- Adds missing fields from PartBriefSerializer
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.14 on 2024-07-12 03:35
|
||||
|
||||
from django.db import migrations, models
|
||||
import importer.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('importer', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dataimportsession',
|
||||
name='field_overrides',
|
||||
field=models.JSONField(blank=True, null=True, validators=[importer.validators.validate_field_defaults], verbose_name='Field Overrides'),
|
||||
),
|
||||
]
|
@ -1,5 +1,6 @@
|
||||
"""Model definitions for the 'importer' app."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@ -32,6 +33,7 @@ class DataImportSession(models.Model):
|
||||
status: IntegerField for the status of the import session
|
||||
user: ForeignKey to the User who initiated the import
|
||||
field_defaults: JSONField for field default values
|
||||
field_overrides: JSONField for field override values
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -92,6 +94,13 @@ class DataImportSession(models.Model):
|
||||
validators=[importer.validators.validate_field_defaults],
|
||||
)
|
||||
|
||||
field_overrides = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Field Overrides'),
|
||||
validators=[importer.validators.validate_field_defaults],
|
||||
)
|
||||
|
||||
@property
|
||||
def field_mapping(self):
|
||||
"""Construct a dict of field mappings for this import session.
|
||||
@ -132,8 +141,15 @@ class DataImportSession(models.Model):
|
||||
|
||||
matched_columns = set()
|
||||
|
||||
field_overrides = self.field_overrides or {}
|
||||
|
||||
# Create a default mapping for each available field in the database
|
||||
for field, field_def in serializer_fields.items():
|
||||
# If an override value is provided for the field,
|
||||
# skip creating a mapping for this field
|
||||
if field in field_overrides:
|
||||
continue
|
||||
|
||||
# Generate a list of possible column names for this field
|
||||
field_options = [
|
||||
field,
|
||||
@ -181,10 +197,15 @@ class DataImportSession(models.Model):
|
||||
required_fields = self.required_fields()
|
||||
|
||||
field_defaults = self.field_defaults or {}
|
||||
field_overrides = self.field_overrides or {}
|
||||
|
||||
missing_fields = []
|
||||
|
||||
for field in required_fields.keys():
|
||||
# An override value exists
|
||||
if field in field_overrides:
|
||||
continue
|
||||
|
||||
# A default value exists
|
||||
if field in field_defaults and field_defaults[field]:
|
||||
continue
|
||||
@ -265,6 +286,18 @@ class DataImportSession(models.Model):
|
||||
self.status = DataImportStatusCode.PROCESSING.value
|
||||
self.save()
|
||||
|
||||
def check_complete(self) -> bool:
|
||||
"""Check if the import session is complete."""
|
||||
if self.completed_row_count < self.row_count:
|
||||
return False
|
||||
|
||||
# Update the status of this session
|
||||
if self.status != DataImportStatusCode.COMPLETE.value:
|
||||
self.status = DataImportStatusCode.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
"""Return the number of rows in the import session."""
|
||||
@ -467,6 +500,34 @@ class DataImportRow(models.Model):
|
||||
|
||||
complete = models.BooleanField(default=False, verbose_name=_('Complete'))
|
||||
|
||||
@property
|
||||
def default_values(self) -> dict:
|
||||
"""Return a dict object of the 'default' values for this row."""
|
||||
defaults = self.session.field_defaults or {}
|
||||
|
||||
if type(defaults) is not dict:
|
||||
try:
|
||||
defaults = json.loads(str(defaults))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Failed to parse default values for import row')
|
||||
defaults = {}
|
||||
|
||||
return defaults
|
||||
|
||||
@property
|
||||
def override_values(self) -> dict:
|
||||
"""Return a dict object of the 'override' values for this row."""
|
||||
overrides = self.session.field_overrides or {}
|
||||
|
||||
if type(overrides) is not dict:
|
||||
try:
|
||||
overrides = json.loads(str(overrides))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Failed to parse override values for import row')
|
||||
overrides = {}
|
||||
|
||||
return overrides
|
||||
|
||||
def extract_data(
|
||||
self, available_fields: dict = None, field_mapping: dict = None, commit=True
|
||||
):
|
||||
@ -477,14 +538,24 @@ class DataImportRow(models.Model):
|
||||
if not available_fields:
|
||||
available_fields = self.session.available_fields()
|
||||
|
||||
default_values = self.session.field_defaults or {}
|
||||
overrride_values = self.override_values
|
||||
default_values = self.default_values
|
||||
|
||||
data = {}
|
||||
|
||||
# We have mapped column (file) to field (serializer) already
|
||||
for field, col in field_mapping.items():
|
||||
# Data override (force value and skip any further checks)
|
||||
if field in overrride_values:
|
||||
data[field] = overrride_values[field]
|
||||
continue
|
||||
|
||||
# Default value (if provided)
|
||||
if field in default_values:
|
||||
data[field] = default_values[field]
|
||||
|
||||
# If this field is *not* mapped to any column, skip
|
||||
if not col:
|
||||
if not col or col not in self.row_data:
|
||||
continue
|
||||
|
||||
# Extract field type
|
||||
@ -516,11 +587,14 @@ class DataImportRow(models.Model):
|
||||
- If available, we use the "default" values provided by the import session
|
||||
- If available, we use the "override" values provided by the import session
|
||||
"""
|
||||
data = self.session.field_defaults or {}
|
||||
data = self.default_values
|
||||
|
||||
if self.data:
|
||||
data.update(self.data)
|
||||
|
||||
# Override values take priority, if present
|
||||
data.update(self.override_values)
|
||||
|
||||
return data
|
||||
|
||||
def construct_serializer(self):
|
||||
@ -568,6 +642,8 @@ class DataImportRow(models.Model):
|
||||
self.complete = True
|
||||
self.save()
|
||||
|
||||
self.session.check_complete()
|
||||
|
||||
except Exception as e:
|
||||
self.errors = {'non_field_errors': str(e)}
|
||||
result = False
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""API serializers for the importer app."""
|
||||
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -47,6 +49,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
||||
'columns',
|
||||
'column_mappings',
|
||||
'field_defaults',
|
||||
'field_overrides',
|
||||
'row_count',
|
||||
'completed_row_count',
|
||||
]
|
||||
@ -75,6 +78,32 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
def validate_field_defaults(self, defaults):
|
||||
"""De-stringify the field defaults."""
|
||||
if defaults is None:
|
||||
return None
|
||||
|
||||
if type(defaults) is not dict:
|
||||
try:
|
||||
defaults = json.loads(str(defaults))
|
||||
except:
|
||||
raise ValidationError(_('Invalid field defaults'))
|
||||
|
||||
return defaults
|
||||
|
||||
def validate_field_overrides(self, overrides):
|
||||
"""De-stringify the field overrides."""
|
||||
if overrides is None:
|
||||
return None
|
||||
|
||||
if type(overrides) is not dict:
|
||||
try:
|
||||
overrides = json.loads(str(overrides))
|
||||
except:
|
||||
raise ValidationError(_('Invalid field overrides'))
|
||||
|
||||
return overrides
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Override create method for this serializer.
|
||||
|
||||
@ -167,4 +196,7 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
|
||||
for row in rows:
|
||||
row.validate(commit=True)
|
||||
|
||||
if session := self.context.get('session', None):
|
||||
session.check_complete()
|
||||
|
||||
return rows
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""Custom validation routines for the 'importer' app."""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -46,4 +46,8 @@ def validate_field_defaults(value):
|
||||
return
|
||||
|
||||
if type(value) is not dict:
|
||||
raise ValidationError(_('Value must be a valid dictionary object'))
|
||||
# OK if we can parse it as JSON
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(_('Value must be a valid dictionary object'))
|
||||
|
@ -1480,28 +1480,30 @@ class BomItemSerializer(
|
||||
):
|
||||
"""Serializer for BomItem object."""
|
||||
|
||||
import_exclude_fields = ['validated', 'substitutes']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
model = BomItem
|
||||
fields = [
|
||||
'part',
|
||||
'sub_part',
|
||||
'reference',
|
||||
'quantity',
|
||||
'overage',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'consumable',
|
||||
'overage',
|
||||
'note',
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_min_total',
|
||||
'pricing_max_total',
|
||||
'pricing_updated',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'substitutes',
|
||||
'validated',
|
||||
|
Reference in New Issue
Block a user