2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

Merge remote-tracking branch 'upstream/master' into barcode-generation

This commit is contained in:
wolflu05
2024-07-15 10:46:19 +02:00
27 changed files with 603 additions and 111 deletions

View File

@ -67,7 +67,7 @@ If you need to process your queue with background workers, run the `worker` task
You can either only run InvenTree or use the integrated debugger for debugging. Goto the `Run and debug` side panel make sure `InvenTree Server` is selected. Click on the play button on the left. You can either only run InvenTree or use the integrated debugger for debugging. Goto the `Run and debug` side panel make sure `InvenTree Server` is selected. Click on the play button on the left.
!!! tip "Debug with 3rd party" !!! tip "Debug with 3rd party"
Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Servre - 3rd party` Sometimes you need to debug also some 3rd party packages. Just select `InvenTree Server - 3rd party`
You can now set breakpoints and vscode will automatically pause execution if that point is hit. You can see all variables available in that context and evaluate some code with the debugger console at the bottom. Use the play or step buttons to continue execution. You can now set breakpoints and vscode will automatically pause execution if that point is hit. You can see all variables available in that context and evaluate some code with the debugger console at the bottom. Use the play or step buttons to continue execution.

View File

@ -6,6 +6,10 @@ title: Stock
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both. A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
## Stock Location Type
A stock location type represents a specific type of location (e.g. one specific size of drawer, shelf, ... or box) which can be assigned to multiple stock locations. In the first place, it is used to specify an icon and having the icon in sync for all locations that use this location type, but it also serves as a data field to quickly see what type of location this is. It is planned to add e.g. drawer dimension information to the location type to add a "find a matching, empty stock location" tool.
## Stock Item ## Stock Item
A *Stock Item* is an actual instance of a [*Part*](../part/part.md) item. It represents a physical quantity of the *Part* in a specific location. A *Stock Item* is an actual instance of a [*Part*](../part/part.md) item. It represents a physical quantity of the *Part* in a specific location.

View File

@ -1,12 +1,20 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 220 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.""" """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 = """
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
- Adds extra exportable fields to BuildItemSerializer
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585 v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
- Adds "revision_of" field to Part serializer - Adds "revision_of" field to Part serializer
- Adds new API filters for "revision" status - Adds new API filters for "revision" status

View File

@ -1072,6 +1072,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name', 'part_name',
'part_ipn', 'part_ipn',
'available_quantity', 'available_quantity',
'item_batch_code',
'item_serial',
] ]
class Meta: class Meta:
@ -1103,6 +1105,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name', 'part_name',
'part_ipn', 'part_ipn',
'available_quantity', 'available_quantity',
'item_batch_code',
'item_serial_number',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1138,6 +1142,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), 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_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), 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(source='build_line.build', many=False, read_only=True) build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)

View File

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

View File

@ -1,5 +1,6 @@
"""Model definitions for the 'importer' app.""" """Model definitions for the 'importer' app."""
import json
import logging import logging
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -32,6 +33,7 @@ class DataImportSession(models.Model):
status: IntegerField for the status of the import session status: IntegerField for the status of the import session
user: ForeignKey to the User who initiated the import user: ForeignKey to the User who initiated the import
field_defaults: JSONField for field default values field_defaults: JSONField for field default values
field_overrides: JSONField for field override values
""" """
@staticmethod @staticmethod
@ -92,6 +94,13 @@ class DataImportSession(models.Model):
validators=[importer.validators.validate_field_defaults], 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 @property
def field_mapping(self): def field_mapping(self):
"""Construct a dict of field mappings for this import session. """Construct a dict of field mappings for this import session.
@ -132,8 +141,15 @@ class DataImportSession(models.Model):
matched_columns = set() matched_columns = set()
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
for field, field_def in serializer_fields.items(): 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 # Generate a list of possible column names for this field
field_options = [ field_options = [
field, field,
@ -181,10 +197,15 @@ class DataImportSession(models.Model):
required_fields = self.required_fields() required_fields = self.required_fields()
field_defaults = self.field_defaults or {} field_defaults = self.field_defaults or {}
field_overrides = self.field_overrides or {}
missing_fields = [] missing_fields = []
for field in required_fields.keys(): for field in required_fields.keys():
# An override value exists
if field in field_overrides:
continue
# A default value exists # A default value exists
if field in field_defaults and field_defaults[field]: if field in field_defaults and field_defaults[field]:
continue continue
@ -265,6 +286,18 @@ class DataImportSession(models.Model):
self.status = DataImportStatusCode.PROCESSING.value self.status = DataImportStatusCode.PROCESSING.value
self.save() 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 @property
def row_count(self): def row_count(self):
"""Return the number of rows in the import session.""" """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')) 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( def extract_data(
self, available_fields: dict = None, field_mapping: dict = None, commit=True self, available_fields: dict = None, field_mapping: dict = None, commit=True
): ):
@ -477,14 +538,24 @@ class DataImportRow(models.Model):
if not available_fields: if not available_fields:
available_fields = self.session.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 = {} data = {}
# 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)
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 this field is *not* mapped to any column, skip
if not col: if not col or col not in self.row_data:
continue continue
# Extract field type # 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 "default" values provided by the import session
- If available, we use the "override" 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: if self.data:
data.update(self.data) data.update(self.data)
# Override values take priority, if present
data.update(self.override_values)
return data return data
def construct_serializer(self): def construct_serializer(self):
@ -568,6 +642,8 @@ class DataImportRow(models.Model):
self.complete = True self.complete = True
self.save() self.save()
self.session.check_complete()
except 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

View File

@ -1,5 +1,7 @@
"""API serializers for the importer app.""" """API serializers for the importer app."""
import json
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -47,6 +49,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'columns', 'columns',
'column_mappings', 'column_mappings',
'field_defaults', 'field_defaults',
'field_overrides',
'row_count', 'row_count',
'completed_row_count', 'completed_row_count',
] ]
@ -75,6 +78,32 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False) 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): def create(self, validated_data):
"""Override create method for this serializer. """Override create method for this serializer.
@ -167,4 +196,7 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
for row in rows: for row in rows:
row.validate(commit=True) row.validate(commit=True)
if session := self.context.get('session', None):
session.check_complete()
return rows return rows

View File

@ -1,6 +1,6 @@
"""Custom validation routines for the 'importer' app.""" """Custom validation routines for the 'importer' app."""
import os import json
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -46,4 +46,8 @@ def validate_field_defaults(value):
return return
if type(value) is not dict: 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'))

View File

@ -309,7 +309,9 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'image', 'image',
'thumbnail', 'thumbnail',
'active', 'active',
'locked',
'assembly', 'assembly',
'component',
'is_template', 'is_template',
'purchaseable', 'purchaseable',
'salable', 'salable',
@ -1478,28 +1480,30 @@ class BomItemSerializer(
): ):
"""Serializer for BomItem object.""" """Serializer for BomItem object."""
import_exclude_fields = ['validated', 'substitutes']
class Meta: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
model = BomItem model = BomItem
fields = [ fields = [
'part',
'sub_part',
'reference',
'quantity',
'overage',
'allow_variants', 'allow_variants',
'inherited', 'inherited',
'note',
'optional', 'optional',
'consumable', 'consumable',
'overage', 'note',
'pk', 'pk',
'part',
'part_detail', 'part_detail',
'pricing_min', 'pricing_min',
'pricing_max', 'pricing_max',
'pricing_min_total', 'pricing_min_total',
'pricing_max_total', 'pricing_max_total',
'pricing_updated', 'pricing_updated',
'quantity',
'reference',
'sub_part',
'sub_part_detail', 'sub_part_detail',
'substitutes', 'substitutes',
'validated', 'validated',

View File

@ -78,21 +78,21 @@ def report_page_size_default():
return page_size return page_size
def encode_image_base64(image, format: str = 'PNG'): def encode_image_base64(image, img_format: str = 'PNG'):
"""Return a base-64 encoded image which can be rendered in an <img> tag. """Return a base-64 encoded image which can be rendered in an <img> tag.
Arguments: Arguments:
image: {Image} -- Image to encode image: {Image} -- Image to encode
format: {str} -- Image format (default = 'PNG') img_format: {str} -- Image format (default = 'PNG')
Returns: Returns:
str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx' str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx'
""" """
fmt = format.lower() img_format = str(img_format).lower()
buffered = io.BytesIO() buffered = io.BytesIO()
image.save(buffered, fmt) image.save(buffered, img_format)
img_str = base64.b64encode(buffered.getvalue()) img_str = base64.b64encode(buffered.getvalue())
return f'data:image/{fmt};charset=utf-8;base64,' + img_str.decode() return f'data:image/{img_format};charset=utf-8;base64,' + img_str.decode()

View File

@ -306,6 +306,7 @@ class StockItemSerializerBrief(
'location', 'location',
'quantity', 'quantity',
'serial', 'serial',
'batch',
'supplier_part', 'supplier_part',
'barcode_hash', 'barcode_hash',
] ]

View File

@ -384,21 +384,40 @@ export function ApiForm({
let method = props.method?.toLowerCase() ?? 'get'; let method = props.method?.toLowerCase() ?? 'get';
let hasFiles = false; let hasFiles = false;
mapFields(fields, (_path, field) => {
if (field.field_type === 'file upload') {
hasFiles = true;
}
});
// Optionally pre-process the data before submitting it // Optionally pre-process the data before submitting it
if (props.processFormData) { if (props.processFormData) {
data = props.processFormData(data); data = props.processFormData(data);
} }
let dataForm = new FormData();
Object.keys(data).forEach((key: string) => {
let value: any = data[key];
let field_type = fields[key]?.field_type;
if (field_type == 'file upload') {
hasFiles = true;
}
// Stringify any JSON objects
if (typeof value === 'object') {
switch (field_type) {
case 'file upload':
break;
default:
value = JSON.stringify(value);
break;
}
}
dataForm.append(key, value);
});
return api({ return api({
method: method, method: method,
url: url, url: url,
data: data, data: hasFiles ? dataForm : data,
timeout: props.timeout, timeout: props.timeout,
headers: { headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
@ -462,7 +481,11 @@ export function ApiForm({
for (const [k, v] of Object.entries(errors)) { for (const [k, v] of Object.entries(errors)) {
const path = _path ? `${_path}.${k}` : k; const path = _path ? `${_path}.${k}` : k;
if (k === 'non_field_errors' || k === '__all__') { // Determine if field "k" is valid (exists and is visible)
let field = fields[k];
let valid = field && !field.hidden;
if (!valid || k === 'non_field_errors' || k === '__all__') {
if (Array.isArray(v)) { if (Array.isArray(v)) {
_nonFieldErrors.push(...v); _nonFieldErrors.push(...v);
} }

View File

@ -21,6 +21,7 @@ import { DependentField } from './DependentField';
import { NestedObjectField } from './NestedObjectField'; import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField'; import { TableField } from './TableField';
import TextField from './TextField';
export type ApiFormData = UseFormReturnType<Record<string, unknown>>; export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
@ -223,21 +224,11 @@ export function ApiFormField({
case 'url': case 'url':
case 'string': case 'string':
return ( return (
<TextInput <TextField
{...reducedDefinition} definition={reducedDefinition}
ref={field.ref} controller={controller}
id={fieldId} fieldName={fieldName}
aria-label={`text-field-${field.name}`} onChange={onChange}
type={definition.field_type}
value={value || ''}
error={error?.message}
radius="sm"
onChange={(event) => onChange(event.currentTarget.value)}
rightSection={
value && !definition.required ? (
<IconX size="1rem" color="red" onClick={() => onChange('')} />
) : null
}
/> />
); );
case 'boolean': case 'boolean':

View File

@ -0,0 +1,66 @@
import { TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react';
import { useCallback, useEffect, useId, useState } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
/*
* Custom implementation of the mantine <TextInput> component,
* used for rendering text input fields in forms.
* Uses a debounced value to prevent excessive re-renders.
*/
export default function TextField({
controller,
fieldName,
definition,
onChange
}: {
controller: UseControllerReturn<FieldValues, any>;
definition: any;
fieldName: string;
onChange: (value: any) => void;
}) {
const fieldId = useId();
const {
field,
fieldState: { error }
} = controller;
const { value } = field;
const [rawText, setRawText] = useState(value);
const [debouncedText] = useDebouncedValue(rawText, 250);
useEffect(() => {
setRawText(value);
}, [value]);
const onTextChange = useCallback((value: any) => {
setRawText(value);
}, []);
useEffect(() => {
if (debouncedText !== value) {
onChange(debouncedText);
}
}, [debouncedText]);
return (
<TextInput
{...definition}
ref={field.ref}
id={fieldId}
aria-label={`text-field-${field.name}`}
type={definition.field_type}
value={rawText || ''}
error={error?.message}
radius="sm"
onChange={(event) => onTextChange(event.currentTarget.value)}
rightSection={
value && !definition.required ? (
<IconX size="1rem" color="red" onClick={() => onTextChange('')} />
) : null
}
/>
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Group, HoverCard, Stack, Text } from '@mantine/core'; import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconArrowRight, IconArrowRight,
@ -26,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
import { ActionButton } from '../buttons/ActionButton'; import { ActionButton } from '../buttons/ActionButton';
import { YesNoButton } from '../buttons/YesNoButton'; import { YesNoButton } from '../buttons/YesNoButton';
import { ApiFormFieldSet } from '../forms/fields/ApiFormField'; import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ProgressBar } from '../items/ProgressBar';
import { RenderRemoteInstance } from '../render/Instance'; import { RenderRemoteInstance } from '../render/Instance';
function ImporterDataCell({ function ImporterDataCell({
@ -178,6 +179,8 @@ export default function ImporterDataSelector({
table.clearSelectedRecords(); table.clearSelectedRecords();
notifications.hide('importing-rows'); notifications.hide('importing-rows');
table.refreshTable(); table.refreshTable();
session.refreshSession();
}); });
}, },
[session.sessionId, table.refreshTable] [session.sessionId, table.refreshTable]
@ -191,6 +194,7 @@ export default function ImporterDataSelector({
title: t`Edit Data`, title: t`Edit Data`,
fields: selectedFields, fields: selectedFields,
initialData: selectedRow.data, initialData: selectedRow.data,
fetchInitialData: false,
processFormData: (data: any) => { processFormData: (data: any) => {
// Construct fields back into a single object // Construct fields back into a single object
return { return {
@ -374,6 +378,18 @@ export default function ImporterDataSelector({
{editRow.modal} {editRow.modal}
{deleteRow.modal} {deleteRow.modal}
<Stack gap="xs"> <Stack gap="xs">
<Paper shadow="xs" p="xs">
<Group grow justify="apart">
<Text size="lg">{t`Processing Data`}</Text>
<Space />
<ProgressBar
maximum={session.rowCount}
value={session.completedRowCount}
progressLabel
/>
<Space />
</Group>
</Paper>
<InvenTreeTable <InvenTreeTable
tableState={table} tableState={table}
columns={columns} columns={columns}
@ -388,7 +404,10 @@ export default function ImporterDataSelector({
enableColumnSwitching: true, enableColumnSwitching: true,
enableColumnCaching: false, enableColumnCaching: false,
enableSelection: true, enableSelection: true,
enableBulkDelete: true enableBulkDelete: true,
afterBulkDelete: () => {
session.refreshSession();
}
}} }}
/> />
</Stack> </Stack>

View File

@ -2,19 +2,23 @@ import { t } from '@lingui/macro';
import { import {
Alert, Alert,
Button, Button,
Divider,
Group, Group,
Paper,
Select, Select,
SimpleGrid, Space,
Stack, Stack,
Table,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ImportSessionState } from '../../hooks/UseImportSession'; import { ImportSessionState } from '../../hooks/UseImportSession';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { StandaloneField } from '../forms/StandaloneField';
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
function ImporterColumn({ column, options }: { column: any; options: any[] }) { function ImporterColumn({ column, options }: { column: any; options: any[] }) {
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
@ -54,6 +58,7 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) {
<Select <Select
error={errorMessage} error={errorMessage}
clearable clearable
searchable
placeholder={t`Select column, or leave blank to ignore this field.`} placeholder={t`Select column, or leave blank to ignore this field.`}
label={undefined} label={undefined}
data={options} data={options}
@ -63,6 +68,92 @@ function ImporterColumn({ column, options }: { column: any; options: any[] }) {
); );
} }
function ImporterDefaultField({
fieldName,
session
}: {
fieldName: string;
session: ImportSessionState;
}) {
const onChange = useCallback(
(value: any) => {
// Update the default value for the field
let defaults = {
...session.fieldDefaults,
[fieldName]: value
};
api
.patch(apiUrl(ApiEndpoints.import_session_list, session.sessionId), {
field_defaults: defaults
})
.then((response: any) => {
session.setSessionData(response.data);
})
.catch(() => {
// TODO: Error message?
});
},
[fieldName, session, session.fieldDefaults]
);
const fieldDef: ApiFormFieldType = useMemo(() => {
let def: any = session.availableFields[fieldName];
if (def) {
def = {
...def,
value: session.fieldDefaults[fieldName],
field_type: def.type,
description: def.help_text,
onValueChange: onChange
};
}
return def;
}, [fieldName, session.availableFields, session.fieldDefaults]);
return (
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
);
}
function ImporterColumnTableRow({
session,
column,
options
}: {
session: ImportSessionState;
column: any;
options: any;
}) {
return (
<Table.Tr key={column.label ?? column.field}>
<Table.Td>
<Group gap="xs">
<Text fw={column.required ? 700 : undefined}>
{column.label ?? column.field}
</Text>
{column.required && (
<Text c="red" fw={700}>
*
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{column.description}</Text>
</Table.Td>
<Table.Td>
<ImporterColumn column={column} options={options} />
</Table.Td>
<Table.Td>
<ImporterDefaultField fieldName={column.field} session={session} />
</Table.Td>
</Table.Tr>
);
}
export default function ImporterColumnSelector({ export default function ImporterColumnSelector({
session session
}: { }: {
@ -88,7 +179,7 @@ export default function ImporterColumnSelector({
const columnOptions: any[] = useMemo(() => { const columnOptions: any[] = useMemo(() => {
return [ return [
{ value: '', label: t`Select a column from the data file` }, { value: '', label: t`Ignore this field` },
...session.availableColumns.map((column: any) => { ...session.availableColumns.map((column: any) => {
return { return {
value: column, value: column,
@ -100,45 +191,44 @@ export default function ImporterColumnSelector({
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Group justify="apart"> <Paper shadow="xs" p="xs">
<Text>{t`Map data columns to database fields`}</Text> <Group grow justify="apart">
<Button <Text size="lg">{t`Mapping data columns to database fields`}</Text>
color="green" <Space />
variant="filled" <Button color="green" variant="filled" onClick={acceptMapping}>
onClick={acceptMapping} <Group>
>{t`Accept Column Mapping`}</Button> <IconCheck />
</Group> {t`Accept Column Mapping`}
</Group>
</Button>
</Group>
</Paper>
{errorMessage && ( {errorMessage && (
<Alert color="red" title={t`Error`}> <Alert color="red" title={t`Error`}>
<Text>{errorMessage}</Text> <Text>{errorMessage}</Text>
</Alert> </Alert>
)} )}
<SimpleGrid cols={3} spacing="xs"> <Table>
<Text fw={700}>{t`Database Field`}</Text> <Table.Thead>
<Text fw={700}>{t`Field Description`}</Text> <Table.Tr>
<Text fw={700}>{t`Imported Column Name`}</Text> <Table.Th>{t`Database Field`}</Table.Th>
<Divider /> <Table.Th>{t`Field Description`}</Table.Th>
<Divider /> <Table.Th>{t`Imported Column`}</Table.Th>
<Divider /> <Table.Th>{t`Default Value`}</Table.Th>
{session.columnMappings.map((column: any) => { </Table.Tr>
return [ </Table.Thead>
<Group gap="xs"> <Table.Tbody>
<Text fw={column.required ? 700 : undefined}> {session.columnMappings.map((column: any) => {
{column.label ?? column.field} return (
</Text> <ImporterColumnTableRow
{column.required && ( session={session}
<Text c="red" fw={700}> column={column}
* options={columnOptions}
</Text> />
)} );
</Group>, })}
<Text size="sm" fs="italic"> </Table.Tbody>
{column.description} </Table>
</Text>,
<ImporterColumn column={column} options={columnOptions} />
];
})}
</SimpleGrid>
</Stack> </Stack>
); );
} }

View File

@ -1,26 +1,26 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
ActionIcon, Alert,
Button,
Divider, Divider,
Drawer, Drawer,
Group, Group,
Loader,
LoadingOverlay, LoadingOverlay,
Paper, Paper,
Space,
Stack, Stack,
Stepper, Stepper,
Text, Text
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconCircleX } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react'; import { ReactNode, useMemo } from 'react';
import { ModelType } from '../../enums/ModelType';
import { import {
ImportSessionStatus, ImportSessionStatus,
useImportSession useImportSession
} from '../../hooks/UseImportSession'; } from '../../hooks/UseImportSession';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
import { StatusRenderer } from '../render/StatusRenderer';
import ImporterDataSelector from './ImportDataSelector'; import ImporterDataSelector from './ImportDataSelector';
import ImporterColumnSelector from './ImporterColumnSelector'; import ImporterColumnSelector from './ImporterColumnSelector';
import ImporterImportProgress from './ImporterImportProgress'; import ImporterImportProgress from './ImporterImportProgress';
@ -39,10 +39,12 @@ function ImportDrawerStepper({ currentStep }: { currentStep: number }) {
active={currentStep} active={currentStep}
onStepClick={undefined} onStepClick={undefined}
allowNextStepsSelect={false} allowNextStepsSelect={false}
iconSize={20}
size="xs" size="xs"
> >
<Stepper.Step label={t`Import Data`} /> <Stepper.Step label={t`Upload File`} />
<Stepper.Step label={t`Map Columns`} /> <Stepper.Step label={t`Map Columns`} />
<Stepper.Step label={t`Import Data`} />
<Stepper.Step label={t`Process Data`} /> <Stepper.Step label={t`Process Data`} />
<Stepper.Step label={t`Complete Import`} /> <Stepper.Step label={t`Complete Import`} />
</Stepper> </Stepper>
@ -60,7 +62,28 @@ export default function ImporterDrawer({
}) { }) {
const session = useImportSession({ sessionId: sessionId }); const session = useImportSession({ sessionId: sessionId });
// Map from import steps to stepper steps
const currentStep = useMemo(() => {
switch (session.status) {
default:
case ImportSessionStatus.INITIAL:
return 0;
case ImportSessionStatus.MAPPING:
return 1;
case ImportSessionStatus.IMPORTING:
return 2;
case ImportSessionStatus.PROCESSING:
return 3;
case ImportSessionStatus.COMPLETE:
return 4;
}
}, [session.status]);
const widget = useMemo(() => { const widget = useMemo(() => {
if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) {
return <Loader />;
}
switch (session.status) { switch (session.status) {
case ImportSessionStatus.INITIAL: case ImportSessionStatus.INITIAL:
return <Text>Initial : TODO</Text>; return <Text>Initial : TODO</Text>;
@ -71,11 +94,29 @@ export default function ImporterDrawer({
case ImportSessionStatus.PROCESSING: case ImportSessionStatus.PROCESSING:
return <ImporterDataSelector session={session} />; return <ImporterDataSelector session={session} />;
case ImportSessionStatus.COMPLETE: case ImportSessionStatus.COMPLETE:
return <Text>Complete!</Text>; return (
<Stack gap="xs">
<Alert
color="green"
title={t`Import Complete`}
icon={<IconCheck />}
>
{t`Data has been imported successfully`}
</Alert>
<Button color="blue" onClick={onClose}>{t`Close`}</Button>
</Stack>
);
default: default:
return <Text>Unknown status code: {session?.status}</Text>; return (
<Stack gap="xs">
<Alert color="red" title={t`Unknown Status`} icon={<IconCheck />}>
{t`Import session has unknown status`}: {session.status}
</Alert>
<Button color="red" onClick={onClose}>{t`Close`}</Button>
</Stack>
);
} }
}, [session.status]); }, [session.status, session.sessionQuery]);
const title: ReactNode = useMemo(() => { const title: ReactNode = useMemo(() => {
return ( return (
@ -87,18 +128,11 @@ export default function ImporterDrawer({
grow grow
preventGrowOverflow={false} preventGrowOverflow={false}
> >
<StylishText> <StylishText size="lg">
{session.sessionData?.statusText ?? t`Importing Data`} {session.sessionData?.statusText ?? t`Importing Data`}
</StylishText> </StylishText>
{StatusRenderer({ <ImportDrawerStepper currentStep={currentStep} />
status: session.status, <Space />
type: ModelType.importsession
})}
<Tooltip label={t`Cancel import session`}>
<ActionIcon color="red" variant="transparent" onClick={onClose}>
<IconCircleX />
</ActionIcon>
</Tooltip>
</Group> </Group>
<Divider /> <Divider />
</Stack> </Stack>
@ -112,7 +146,7 @@ export default function ImporterDrawer({
title={title} title={title}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
withCloseButton={false} withCloseButton={true}
closeOnEscape={false} closeOnEscape={false}
closeOnClickOutside={false} closeOnClickOutside={false}
styles={{ styles={{

View File

@ -134,7 +134,11 @@ export function RenderRemoteInstance({
} }
if (!data) { if (!data) {
return <Text>${pk}</Text>; return (
<Text>
{model}: {pk}
</Text>
);
} }
return <RenderInstance model={model} instance={data} />; return <RenderInstance model={model} instance={data} />;

View File

@ -4,8 +4,13 @@ export function dataImporterSessionFields(): ApiFormFieldSet {
return { return {
data_file: {}, data_file: {},
model_type: {}, model_type: {},
field_detauls: { field_defaults: {
hidden: true hidden: true,
value: {}
},
field_overrides: {
hidden: true,
value: {}
} }
}; };
} }

View File

@ -21,6 +21,7 @@ export enum ImportSessionStatus {
export type ImportSessionState = { export type ImportSessionState = {
sessionId: number; sessionId: number;
sessionData: any; sessionData: any;
setSessionData: (data: any) => void;
refreshSession: () => void; refreshSession: () => void;
sessionQuery: any; sessionQuery: any;
status: ImportSessionStatus; status: ImportSessionStatus;
@ -28,6 +29,10 @@ export type ImportSessionState = {
availableColumns: string[]; availableColumns: string[];
mappedFields: any[]; mappedFields: any[];
columnMappings: any[]; columnMappings: any[];
fieldDefaults: any;
fieldOverrides: any;
rowCount: number;
completedRowCount: number;
}; };
export function useImportSession({ export function useImportSession({
@ -38,6 +43,7 @@ export function useImportSession({
// Query manager for the import session // Query manager for the import session
const { const {
instance: sessionData, instance: sessionData,
setInstance,
refreshInstance: refreshSession, refreshInstance: refreshSession,
instanceQuery: sessionQuery instanceQuery: sessionQuery
} = useInstance({ } = useInstance({
@ -46,6 +52,12 @@ export function useImportSession({
defaultValue: {} defaultValue: {}
}); });
const setSessionData = useCallback((data: any) => {
console.log('setting session data:');
console.log(data);
setInstance(data);
}, []);
// Current step of the import process // Current step of the import process
const status: ImportSessionStatus = useMemo(() => { const status: ImportSessionStatus = useMemo(() => {
return sessionData?.status ?? ImportSessionStatus.INITIAL; return sessionData?.status ?? ImportSessionStatus.INITIAL;
@ -93,8 +105,25 @@ export function useImportSession({
); );
}, [sessionData]); }, [sessionData]);
const fieldDefaults: any = useMemo(() => {
return sessionData?.field_defaults ?? {};
}, [sessionData]);
const fieldOverrides: any = useMemo(() => {
return sessionData?.field_overrides ?? {};
}, [sessionData]);
const rowCount: number = useMemo(() => {
return sessionData?.row_count ?? 0;
}, [sessionData]);
const completedRowCount: number = useMemo(() => {
return sessionData?.completed_row_count ?? 0;
}, [sessionData]);
return { return {
sessionData, sessionData,
setSessionData,
sessionId, sessionId,
refreshSession, refreshSession,
sessionQuery, sessionQuery,
@ -102,6 +131,10 @@ export function useImportSession({
availableFields, availableFields,
availableColumns, availableColumns,
columnMappings, columnMappings,
mappedFields mappedFields,
fieldDefaults,
fieldOverrides,
rowCount,
completedRowCount
}; };
} }

View File

@ -93,5 +93,11 @@ export function useInstance<T = any>({
instanceQuery.refetch(); instanceQuery.refetch();
}, []); }, []);
return { instance, refreshInstance, instanceQuery, requestStatus }; return {
instance,
setInstance,
refreshInstance,
instanceQuery,
requestStatus
};
} }

View File

@ -57,7 +57,6 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable'; import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';

View File

@ -103,6 +103,7 @@ export type InvenTreeTableProps<T = any> = {
enableColumnCaching?: boolean; enableColumnCaching?: boolean;
enableLabels?: boolean; enableLabels?: boolean;
enableReports?: boolean; enableReports?: boolean;
afterBulkDelete?: () => void;
pageSize?: number; pageSize?: number;
barcodeActions?: React.ReactNode[]; barcodeActions?: React.ReactNode[];
tableFilters?: TableFilter[]; tableFilters?: TableFilter[];
@ -547,6 +548,9 @@ export function InvenTreeTable<T = any>({
}) })
.finally(() => { .finally(() => {
tableState.clearSelectedRecords(); tableState.clearSelectedRecords();
if (props.afterBulkDelete) {
props.afterBulkDelete();
}
}); });
} }
}); });

View File

@ -4,6 +4,7 @@ import { showNotification } from '@mantine/notifications';
import { import {
IconArrowRight, IconArrowRight,
IconCircleCheck, IconCircleCheck,
IconFileArrowLeft,
IconLock, IconLock,
IconSwitch3 IconSwitch3
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@ -15,11 +16,13 @@ import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton'; import { YesNoButton } from '../../components/buttons/YesNoButton';
import { Thumbnail } from '../../components/images/Thumbnail'; import { Thumbnail } from '../../components/images/Thumbnail';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { bomItemFields } from '../../forms/BomForms'; import { bomItemFields } from '../../forms/BomForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
useApiFormModal, useApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
@ -70,6 +73,12 @@ export function BomTable({
const table = useTable('bom'); const table = useTable('bom');
const navigate = useNavigate(); const navigate = useNavigate();
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
{ {
@ -345,6 +354,29 @@ export function BomTable({
const [selectedBomItem, setSelectedBomItem] = useState<number>(0); const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
const importSessionFields = useMemo(() => {
let fields = dataImporterSessionFields();
fields.model_type.hidden = true;
fields.model_type.value = 'bomitem';
fields.field_overrides.value = {
part: partId
};
return fields;
}, [partId]);
const importBomItem = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import BOM Data`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setImportOpened(true);
}
});
const newBomItem = useCreateApiFormModal({ const newBomItem = useCreateApiFormModal({
url: ApiEndpoints.bom_list, url: ApiEndpoints.bom_list,
title: t`Add BOM Item`, title: t`Add BOM Item`,
@ -467,6 +499,12 @@ export function BomTable({
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<ActionButton
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Import BOM Data`}
icon={<IconFileArrowLeft />}
onClick={() => importBomItem.open()}
/>,
<ActionButton <ActionButton
hidden={partLocked || !user.hasChangeRole(UserRoles.part)} hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
tooltip={t`Validate BOM`} tooltip={t`Validate BOM`}
@ -483,6 +521,7 @@ export function BomTable({
return ( return (
<> <>
{importBomItem.modal}
{newBomItem.modal} {newBomItem.modal}
{editBomItem.modal} {editBomItem.modal}
{validateBom.modal} {validateBom.modal}
@ -515,10 +554,20 @@ export function BomTable({
modelField: 'sub_part', modelField: 'sub_part',
rowActions: rowActions, rowActions: rowActions,
enableSelection: !partLocked, enableSelection: !partLocked,
enableBulkDelete: !partLocked enableBulkDelete: !partLocked,
enableDownload: true
}} }}
/> />
</Stack> </Stack>
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession !== undefined && importOpened}
onClose={() => {
setSelectedSession(undefined);
setImportOpened(false);
table.refreshTable();
}}
/>
</> </>
); );
} }

View File

@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { import {
useDeleteApiFormModal, useDeleteApiFormModal,
@ -58,6 +59,13 @@ export default function BuildAllocatedStockTable({
sortable: true, sortable: true,
switchable: false switchable: false
}, },
{
accessor: 'serial',
title: t`Serial Number`,
sortable: false,
switchable: true,
render: (record: any) => record?.stock_item_detail?.serial
},
{ {
accessor: 'batch', accessor: 'batch',
title: t`Batch Code`, title: t`Batch Code`,
@ -150,7 +158,9 @@ export default function BuildAllocatedStockTable({
enableDownload: true, enableDownload: true,
enableSelection: true, enableSelection: true,
rowActions: rowActions, rowActions: rowActions,
tableFilters: tableFilters tableFilters: tableFilters,
modelField: 'stock_item',
modelType: ModelType.stockitem
}} }}
/> />
</> </>

View File

@ -19,10 +19,10 @@ test('PUI - Pages - Build Order', async ({ page }) => {
await page.getByRole('tab', { name: 'Allocated Stock' }).click(); await page.getByRole('tab', { name: 'Allocated Stock' }).click();
// Check for expected text in the table // Check for expected text in the table
await page.getByText('R_10R_0402_1%').click(); await page.getByText('R_10R_0402_1%').waitFor();
await page await page
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' }) .getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
.click(); .waitFor();
// Click through to the "parent" build // Click through to the "parent" build
await page.getByRole('tab', { name: 'Build Details' }).click(); await page.getByRole('tab', { name: 'Build Details' }).click();

View File

@ -180,6 +180,10 @@ test('PUI - Pages - Part - Attachments', async ({ page }) => {
await page.getByLabel('action-button-add-external-').click(); await page.getByLabel('action-button-add-external-').click();
await page.getByLabel('text-field-link').fill('https://www.google.com'); await page.getByLabel('text-field-link').fill('https://www.google.com');
await page.getByLabel('text-field-comment').fill('a sample comment'); await page.getByLabel('text-field-comment').fill('a sample comment');
// Note: Text field values are debounced for 250ms
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor(); await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor();