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

This commit is contained in:
wolflu05 2024-07-15 10:46:19 +02:00
commit 508258b362
No known key found for this signature in database
GPG Key ID: 9099EFC7C5EB963C
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.
!!! 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.

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.
## 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
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
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."""
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
- Adds "revision_of" field to Part serializer
- Adds new API filters for "revision" status

View File

@ -1072,6 +1072,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name',
'part_ipn',
'available_quantity',
'item_batch_code',
'item_serial',
]
class Meta:
@ -1103,6 +1105,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'part_name',
'part_ipn',
'available_quantity',
'item_batch_code',
'item_serial_number',
]
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_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
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."""
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

View File

@ -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

View File

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

View File

@ -309,7 +309,9 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'image',
'thumbnail',
'active',
'locked',
'assembly',
'component',
'is_template',
'purchaseable',
'salable',
@ -1478,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',

View File

@ -78,21 +78,21 @@ def report_page_size_default():
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.
Arguments:
image: {Image} -- Image to encode
format: {str} -- Image format (default = 'PNG')
img_format: {str} -- Image format (default = 'PNG')
Returns:
str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx'
"""
fmt = format.lower()
img_format = str(img_format).lower()
buffered = io.BytesIO()
image.save(buffered, fmt)
image.save(buffered, img_format)
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',
'quantity',
'serial',
'batch',
'supplier_part',
'barcode_hash',
]

View File

@ -384,21 +384,40 @@ export function ApiForm({
let method = props.method?.toLowerCase() ?? 'get';
let hasFiles = false;
mapFields(fields, (_path, field) => {
if (field.field_type === 'file upload') {
hasFiles = true;
}
});
// Optionally pre-process the data before submitting it
if (props.processFormData) {
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({
method: method,
url: url,
data: data,
data: hasFiles ? dataForm : data,
timeout: props.timeout,
headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
@ -462,7 +481,11 @@ export function ApiForm({
for (const [k, v] of Object.entries(errors)) {
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)) {
_nonFieldErrors.push(...v);
}

View File

@ -21,6 +21,7 @@ import { DependentField } from './DependentField';
import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TextField from './TextField';
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
@ -223,21 +224,11 @@ export function ApiFormField({
case 'url':
case 'string':
return (
<TextInput
{...reducedDefinition}
ref={field.ref}
id={fieldId}
aria-label={`text-field-${field.name}`}
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
}
<TextField
definition={reducedDefinition}
controller={controller}
fieldName={fieldName}
onChange={onChange}
/>
);
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 { Group, HoverCard, Stack, Text } from '@mantine/core';
import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconArrowRight,
@ -26,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
import { ActionButton } from '../buttons/ActionButton';
import { YesNoButton } from '../buttons/YesNoButton';
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ProgressBar } from '../items/ProgressBar';
import { RenderRemoteInstance } from '../render/Instance';
function ImporterDataCell({
@ -178,6 +179,8 @@ export default function ImporterDataSelector({
table.clearSelectedRecords();
notifications.hide('importing-rows');
table.refreshTable();
session.refreshSession();
});
},
[session.sessionId, table.refreshTable]
@ -191,6 +194,7 @@ export default function ImporterDataSelector({
title: t`Edit Data`,
fields: selectedFields,
initialData: selectedRow.data,
fetchInitialData: false,
processFormData: (data: any) => {
// Construct fields back into a single object
return {
@ -374,6 +378,18 @@ export default function ImporterDataSelector({
{editRow.modal}
{deleteRow.modal}
<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
tableState={table}
columns={columns}
@ -388,7 +404,10 @@ export default function ImporterDataSelector({
enableColumnSwitching: true,
enableColumnCaching: false,
enableSelection: true,
enableBulkDelete: true
enableBulkDelete: true,
afterBulkDelete: () => {
session.refreshSession();
}
}}
/>
</Stack>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconFileArrowLeft,
IconLock,
IconSwitch3
} from '@tabler/icons-react';
@ -15,11 +16,13 @@ import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton';
import { Thumbnail } from '../../components/images/Thumbnail';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { bomItemFields } from '../../forms/BomForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import {
useApiFormModal,
useCreateApiFormModal,
@ -70,6 +73,12 @@ export function BomTable({
const table = useTable('bom');
const navigate = useNavigate();
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
@ -345,6 +354,29 @@ export function BomTable({
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({
url: ApiEndpoints.bom_list,
title: t`Add BOM Item`,
@ -467,6 +499,12 @@ export function BomTable({
const tableActions = useMemo(() => {
return [
<ActionButton
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Import BOM Data`}
icon={<IconFileArrowLeft />}
onClick={() => importBomItem.open()}
/>,
<ActionButton
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
tooltip={t`Validate BOM`}
@ -483,6 +521,7 @@ export function BomTable({
return (
<>
{importBomItem.modal}
{newBomItem.modal}
{editBomItem.modal}
{validateBom.modal}
@ -515,10 +554,20 @@ export function BomTable({
modelField: 'sub_part',
rowActions: rowActions,
enableSelection: !partLocked,
enableBulkDelete: !partLocked
enableBulkDelete: !partLocked,
enableDownload: true
}}
/>
</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 { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import {
useDeleteApiFormModal,
@ -58,6 +59,13 @@ export default function BuildAllocatedStockTable({
sortable: true,
switchable: false
},
{
accessor: 'serial',
title: t`Serial Number`,
sortable: false,
switchable: true,
render: (record: any) => record?.stock_item_detail?.serial
},
{
accessor: 'batch',
title: t`Batch Code`,
@ -150,7 +158,9 @@ export default function BuildAllocatedStockTable({
enableDownload: true,
enableSelection: true,
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();
// 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
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
.click();
.waitFor();
// Click through to the "parent" build
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('text-field-link').fill('https://www.google.com');
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('cell', { name: 'a sample comment' }).first().waitFor();