Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

[#11222](https://github.com/inventree/InvenTree/pull/11222) adds support for data import using natural keys, allowing for easier association of related objects without needing to know their internal database IDs.

### Changed

### Removed
Expand Down
30 changes: 30 additions & 0 deletions docs/docs/settings/import.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ External data can be imported via the admin interface, allowing for rapid integr

To import data, the user must have the appropriate permissions. The user must be a *staff* user, and have the `change` permission for the model in question.

### Mapping to Existing Data

Many data models in InvenTree have relationships to other models. When importing data, the user must ensure that the related data is correctly mapped to existing records in the database. For example, when importing a list of parts, the user must ensure that the *part category* data has already been imported, and that the part category field in the imported file is correctly mapped to the existing part category records in the database.

!!! warning "Multi Level Import"
Multi-level imports are explicitly not supported. Only one model can be imported at a time, and the user must ensure that any related data is imported beforehand.

### Primary Key Fields

The default field used to map to existing data (i.e. related models which have already been imported into the database) is using the `ID` (primary key) field. Thus, it is important to ensure that the imported data file contains the correct `ID` values for any related data, otherwise the import process will fail to correctly link the imported data to existing records in the database.

Some models allow for mapping based on other "natural key" fields (e.g. the `reference` field for orders, or the `name` field for part categories). In such cases, the user must ensure that the correct field is mapped to the relevant column in the imported data file.

!!! warning "Unique Identifiers"
If a unique identifier cannot be determined for any related field, the user must manually map the relevant field to the correct existing record in the database, during the import process.

## Import Session

Importing data is a multi-step process, which is managed via an *import session*. An import session is created when the user initiates a data import, and is used to track the progress of the data import process.
Expand All @@ -33,6 +49,20 @@ Import sessions can be managed from the [Admin Center](./admin.md#admin-center)

Depending on the type of data being imported, an import session can be created from an appropriate page context in the user interface. In such cases, the import session will be automatically linked to the relevant data type being imported.

### Starting an Import Session

An import session can be initiated from a number of different contexts within the user interface:

### Admin Center

Staff users can create an import session from within the [Admin Center](./admin.md#admin-center). This is a general-purpose import session, and the user will be required to select the type of data to import.

Users can quickly navigate to the data import managemement page from the [spotlight search](../concepts/user_interface.md#spotlight), by searching for "import" and selecting the "Import data" option.

### Data Tables

Some data tables allow the user to create an import session directly from the table view. In such cases, the import session will be automatically linked to the relevant data type being imported, and additional [context information](#context-sensitive-importing) will be automatically provided.

## Import Process

The following steps outline the process of importing data into InvenTree:
Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/build/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class Build(
"""

STATUS_CLASS = BuildStatus
IMPORT_ID_FIELDS = ['reference']

class Meta:
"""Metaclass options for the BuildOrder model."""
Expand Down
4 changes: 4 additions & 0 deletions src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ def save(self, *args, **kwargs):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""

IMPORT_ID_FIELDS = ['code']

class Meta:
"""Class options for the ProjectCode model."""

Expand Down Expand Up @@ -2396,6 +2398,8 @@ class ParameterTemplate(
enabled: Is this template enabled?
"""

IMPORT_ID_FIELDS = ['name']

class Meta:
"""Metaclass options for the ParameterTemplate model."""

Expand Down
7 changes: 7 additions & 0 deletions src/backend/InvenTree/company/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class Company(
"""

IMAGE_RENAME = rename_company_image
IMPORT_ID_FIELDS = ['name']

class Meta:
"""Metaclass defines extra model options."""
Expand Down Expand Up @@ -297,6 +298,8 @@ class Contact(InvenTree.models.InvenTreeMetadataModel):
role: position in company
"""

IMPORT_ID_FIELDS = ['name', 'email']

class Meta:
"""Metaclass defines extra model options."""

Expand Down Expand Up @@ -494,6 +497,8 @@ class ManufacturerPart(
description: Descriptive notes field
"""

IMPORT_ID_FIELDS = ['MPN']

class Meta:
"""Metaclass defines extra model options."""

Expand Down Expand Up @@ -620,6 +625,8 @@ class SupplierPart(
updated: Date that the SupplierPart was last updated
"""

IMPORT_ID_FIELDS = ['SKU']

class Meta:
"""Metaclass defines extra model options."""

Expand Down
115 changes: 114 additions & 1 deletion src/backend/InvenTree/importer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from collections import OrderedDict
from datetime import datetime
from typing import Optional

from django.contrib.auth.models import User
Expand Down Expand Up @@ -151,6 +152,27 @@ def serializer_class(self):

return supported_models().get(self.model_type, None)

def get_related_model(self, field_name: str) -> models.Model:
"""Return the related model for a given field name.

Arguments:
field_name: The name of the field to check

Returns:
The related model class, if one exists, or None otherwise
"""
model_class = self.model_class

if not model_class:
return None

try:
related_field = model_class._meta.get_field(field_name)
model = related_field.remote_field.model
return model
except (AttributeError, models.FieldDoesNotExist):
return None

def extract_columns(self) -> None:
"""Run initial column extraction and mapping.

Expand Down Expand Up @@ -597,6 +619,8 @@ def extract_data(

data = {}

self.related_field_map = {}

# 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)
Expand All @@ -622,7 +646,9 @@ def extract_data(
if field_type == 'boolean':
value = InvenTree.helpers.str2bool(value)
elif field_type == 'date':
value = value or None
value = self.convert_date_field(value)
elif field_type == 'related field':
value = self.lookup_related_field(field, value)

# Use the default value, if provided
if value is None and field in default_values:
Expand Down Expand Up @@ -667,6 +693,93 @@ def extract_data(
if commit:
self.save()

def convert_date_field(self, value: str) -> str:
"""Convert an incoming date field to the correct format for the database."""
if value in [None, '']:
return None

# Attempt conversion using accepted formats
date_formats = ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%Y/%m/%d']

for fmt in date_formats:
try:
dt = datetime.strptime(value.strip(), fmt)

# If the date is valid, convert it to the standard format and return
return dt.strftime('%Y-%m-%d')
except ValueError:
continue

# If none of the formats matched, return the original value
return value

def lookup_related_field(self, field_name: str, value: str) -> Optional[int]:
"""Try to perform lookup against a related field.

- This is used to convert a human-readable value (e.g. a supplier name) into a database reference (e.g. supplier ID).
- Reference the value against the related model's allowable import fields

Arguments:
field_name: The name of the field to perform the lookup against
value: The value to be looked up

Returns:
A primary key value
"""
if value is None or value == '':
return value

if field_name is None or field_name == '':
return value

if field_name in self.related_field_map:
model = self.related_field_map[field_name]
else:
# Cache the related model for this field name
model = self.related_field_map[field_name] = self.session.get_related_model(
field_name
)

if not model:
raise DjangoValidationError({
'session': f'No related model found for field: {field_name}'
})

valid_items = set()

base_filters = (
self.session.field_filters.get(field_name, {})
if self.session.field_filters
else {}
)

# First priority is the PK (primary key) field
id_fields = ['pk']

if custom_id_fields := getattr(model, 'IMPORT_ID_FIELDS', None):
id_fields += custom_id_fields

# Iterate through the provided list - if any of the values match, we can perform the lookup
for id_field in id_fields:
try:
queryset = model.objects.filter(**{id_field: value}, **base_filters)
except ValueError:
continue

# Evaluate at most two results to determine if there is exactly one match
results = list(queryset[:2])
if len(results) == 1:
# We have a single match against this field
valid_items.add(results[0].pk)

if len(valid_items) == 1:
# We found a single valid match against the related model - return this value
return valid_items.pop()

# We found either zero or multiple values matching against the related model
# Return the original value and let the serializer validation handle any errors against this field
return value

def serializer_data(self):
"""Construct data object to be sent to the serializer.

Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/order/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ class Order(

REQUIRE_RESPONSIBLE_SETTING = None
UNLOCK_SETTING = None
IMPORT_ID_FIELDS = ['reference']

class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
Expand Down
5 changes: 4 additions & 1 deletion src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ class PartCategory(
"""

ITEM_PARENT_KEY = 'category'

EXTRA_PATH_FIELDS = ['icon']
IMPORT_ID_FIELDS = ['pathstring', 'name']

class Meta:
"""Metaclass defines extra model properties."""
Expand Down Expand Up @@ -517,6 +517,7 @@ class Part(

NODE_PARENT_KEY = 'variant_of'
IMAGE_RENAME = rename_part_image
IMPORT_ID_FIELDS = ['IPN', 'name']

objects = TreeManager()

Expand Down Expand Up @@ -3635,6 +3636,8 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
run on the model (refer to the validate_unique function).
"""

IMPORT_ID_FIELDS = ['key']

class Meta:
"""Metaclass options for the PartTestTemplate model."""

Expand Down
5 changes: 4 additions & 1 deletion src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class StockLocationType(InvenTree.models.MetadataMixin, models.Model):
icon: icon class
"""

IMPORT_ID_FIELDS = ['name']

class Meta:
"""Metaclass defines extra model properties."""

Expand Down Expand Up @@ -134,8 +136,8 @@ class StockLocation(
"""

ITEM_PARENT_KEY = 'location'

EXTRA_PATH_FIELDS = ['icon']
IMPORT_ID_FIELDS = ['pathstring', 'name']

objects = TreeManager()

Expand Down Expand Up @@ -434,6 +436,7 @@ class StockItem(
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
"""

IMPORT_ID_FIELDS = ['serial']
STATUS_CLASS = StockStatus

class Meta:
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/tests/fixtures/po_data_natural_keys.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Project Code,Quantity,Reference,Target Date,Project Code Label,Build Order,Overdue,Received,Purchase price,Currency,Auto Pricing,Destination,Total price,SKU,MPN,Internal Part Number,Internal Part,Internal Part Name
5,123,,30/01/2026,PRO-ZEN,,TRUE,0,0.48,USD,TRUE,,59.04,FUT-43861-DDU,,,55,C_100nF_0603
,9999,my-custom-reference,,,,FALSE,0,0.1179,USD,TRUE,9,1178.8821,FUT-82092-CQB,,,60,C_10uF_0805
50 changes: 50 additions & 0 deletions src/frontend/tests/pui_importing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,53 @@ test('Importing - Purchase Order', async ({ browser }) => {
await page.getByRole('cell', { name: 'Database Field' }).waitFor();
await page.getByRole('cell', { name: 'Field Description' }).waitFor();
});

test('Importing - Natural Keys', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
url: 'purchasing/purchase-order/15/line-items'
});

// Import line item data, but use natural keys as the import fields
await page
.getByRole('button', { name: 'action-button-import-line-' })
.click();

const fileInput = await page.locator('input[type="file"]');
await fileInput.setInputFiles('./tests/fixtures/po_data_natural_keys.csv');
await page.getByRole('button', { name: 'Submit' }).click();

// Attempt import with missing required fields
await page.getByRole('button', { name: 'Accept Column Mapping' }).click();
await page.getByText('Some required fields have not been mapped').waitFor();

// Select different columns for data import
// We will use the "SKU" field to map to the supplier part
await page.getByRole('textbox', { name: 'import-column-map-part' }).click();
await page.getByRole('option', { name: 'SKU' }).click();

// Other import fields will be left as default
await page.getByRole('button', { name: 'Accept Column Mapping' }).click();

// Check for expected values to be displayed
await page.getByText('PRO-ZEN').first().waitFor();
await page.getByText('Project Zenith').first().waitFor();
await page.getByText('my-custom-reference').first().waitFor();
await page.getByText('Factory/Mechanical Lab').first().waitFor();
await page.getByText('FUT-43861-DDU').first().waitFor();
await page.getByText('FUT-82092-CQB').first().waitFor();
await page.getByText('2026-01-30').first().waitFor();

// Let's import all the data
await page
.getByRole('row', { name: 'Select all records Row Not' })
.getByLabel('Select all records')
.click();
await page
.getByRole('button', { name: 'action-button-import-selected' })
.click();

await page.getByText('Data has been imported successfully').waitFor();
await page.getByRole('button', { name: 'Close' }).click();
});
Loading