diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml
index 97ddf2a6fc..e894fc30d4 100644
--- a/.github/workflows/frontend.yaml
+++ b/.github/workflows/frontend.yaml
@@ -8,7 +8,7 @@ name: Frontend
on:
push:
- branches-ignore: ["l10*"]
+ branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]
diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml
index 1f0599f0b5..9b33ab8cab 100644
--- a/.github/workflows/import_export.yaml
+++ b/.github/workflows/import_export.yaml
@@ -7,7 +7,7 @@ name: Import / Export
on:
push:
- branches-ignore: ["l10*"]
+ branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]
diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index a4d2c3e1f2..2b3419ac64 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -4,7 +4,7 @@ name: QC
on:
push:
- branches-ignore: ["l10*"]
+ branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 3dc2176956..1795c7ce0e 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -42,6 +42,8 @@ jobs:
id-token: write
contents: write
attestations: write
+ artifact-metadata: write
+
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
@@ -73,7 +75,7 @@ jobs:
zip -r ../frontend-build.zip * .vite
- name: Attest Build Provenance
id: attest
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # pin@v1
+ uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # pin@v4
with:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
@@ -87,11 +89,15 @@ jobs:
with:
name: frontend-build
path: src/backend/InvenTree/web/static/frontend-build.zip
+ - name: Rename Attestation Bundle
+ run: |
+ mv ${BUNDLE_PATH} src/backend/InvenTree/web/static/frontend-build.intoto.jsonl
+ env:
+ BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path}}
- name: Upload Attestation
- run: gh release upload ${REF} ${BUNDLE_PATH}#frontend-build.intoto.jsonl
+ run: gh release upload ${REF} src/backend/InvenTree/web/static/frontend-build.intoto.jsonl#frontend-build.intoto.jsonl
env:
REF: ${{ github.ref_name }}
- BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path}}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docs:
@@ -148,12 +154,12 @@ jobs:
- ubuntu:22.04
- ubuntu:24.04
- debian:12
+
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # pin@v6.0.3
with:
fetch-depth: 0
persist-credentials: false
-
- name: Get frontend artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with:
@@ -209,7 +215,7 @@ jobs:
echo "calculate release channel"
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
python3 .github/scripts/version_check.py
- - name: Package
+ - name: Package - current release channel
uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
id: package
with:
@@ -235,14 +241,6 @@ jobs:
repository: inventree/InvenTree
channel: ${{ env.pkg_channel }}
file: ${{ steps.package.outputs.package_path }}
- - name: Publish to go.packager.io - stable release channel
- uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
- with:
- target: ${{ matrix.target }}
- token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
- repository: inventree/InvenTree
- channel: stable
- file: ${{ steps.package.outputs.package_path }}
- name: Publish to artifact
run: gh release upload ${REF} ${PACKAGE_PATH}#${PACKAGE_NAME}
env:
@@ -250,3 +248,29 @@ jobs:
PACKAGE_PATH: ${{ steps.package.outputs.package_path }}
PACKAGE_NAME: ${{ matrix.target }}-${{ steps.setup.outputs.version }}.tar.gz
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Package - stable release channel
+ uses: pkgr/action/package@c5666febcd31750da6428042193fc5b2fb765435 # pin@main
+ id: package-stable
+ with:
+ target: ${{ matrix.target }}
+ version: ${{ steps.setup.outputs.version }}
+ debug: true
+ cache_prefix: ${{ github.ref_name }}
+ env: |
+ INVENTREE_DB_ENGINE=sqlite3
+ INVENTREE_DB_NAME=database.sqlite3
+ INVENTREE_PLUGINS_ENABLED=true
+ INVENTREE_MEDIA_ROOT=/opt/inventree/media
+ INVENTREE_STATIC_ROOT=/opt/inventree/static
+ INVENTREE_BACKUP_DIR=/opt/inventree/backup
+ INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
+ INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
+ APP_REPO=inventree/InvenTree
+ - name: Publish to go.packager.io - stable release channel
+ uses: pkgr/action/publish@3bce081ae512c5020856e237d37b3f5479d4aa71 # pin@main
+ with:
+ target: ${{ matrix.target }}
+ token: ${{ secrets.PACKAGER_RELEASE_TOKEN }}
+ repository: inventree/InvenTree
+ channel: stable
+ file: ${{ steps.package-stable.outputs.package_path }}
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 016e638b01..8fa1bfeea1 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,16 +1,22 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 508
+INVENTREE_API_VERSION = 510
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
-v508 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
+v510 -> 2026-06-18 : https://github.com/inventree/InvenTree/pull/11971
- Removes direct "notes" field from any models which previously supported markdown notes
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
+v509 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/12184
+ - Adds "completed_row_count_history" and "row_count_history" fields to the DataImportSession model, which store the historic count of completed rows and total rows for a data import session.
+
+v508 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/11982
+ - An order's "status_custom_key" can be updated via PATCH API endpoint
+
v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180
- Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
diff --git a/src/backend/InvenTree/importer/migrations/0007_dataimportsession_completed_row_count_history_and_more.py b/src/backend/InvenTree/importer/migrations/0007_dataimportsession_completed_row_count_history_and_more.py
new file mode 100644
index 0000000000..c70be9e5b3
--- /dev/null
+++ b/src/backend/InvenTree/importer/migrations/0007_dataimportsession_completed_row_count_history_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.15 on 2026-06-17 05:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('importer', '0006_dataimportcolumnmap_lookup_field'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='dataimportsession',
+ name='completed_row_count_history',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Completed Row Count History'),
+ ),
+ migrations.AddField(
+ model_name='dataimportsession',
+ name='row_count_history',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Row Count History'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/importer/models.py b/src/backend/InvenTree/importer/models.py
index 042e8df43b..00f361be02 100644
--- a/src/backend/InvenTree/importer/models.py
+++ b/src/backend/InvenTree/importer/models.py
@@ -385,7 +385,13 @@ class DataImportSession(models.Model):
if self.status != DataImportStatusCode.COMPLETE.value:
self.status = DataImportStatusCode.COMPLETE.value
+
+ # persist historic count values for reporting purposes
+ self.completed_row_count_history = self.completed_row_count
+ self.row_count_history = self.row_count
+
self.save()
+
# Clear staging data now that all rows have been imported
self.rows.all().delete()
self.column_mappings.all().delete()
@@ -402,6 +408,14 @@ class DataImportSession(models.Model):
"""Return the number of completed rows for this session."""
return self.rows.filter(complete=True).count()
+ # Historic values for reporting purposes
+ completed_row_count_history = models.PositiveIntegerField(
+ blank=True, null=True, verbose_name=_('Completed Row Count History')
+ )
+ row_count_history = models.PositiveIntegerField(
+ blank=True, null=True, verbose_name=_('Row Count History')
+ )
+
def available_fields(self):
"""Returns information on the available fields.
diff --git a/src/backend/InvenTree/importer/serializers.py b/src/backend/InvenTree/importer/serializers.py
index 2da60e0e32..46bc8562fc 100644
--- a/src/backend/InvenTree/importer/serializers.py
+++ b/src/backend/InvenTree/importer/serializers.py
@@ -62,6 +62,8 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'field_filters',
'row_count',
'completed_row_count',
+ 'completed_row_count_history',
+ 'row_count_history',
]
read_only_fields = ['pk', 'user', 'status', 'columns']
@@ -220,6 +222,8 @@ class DataImportAcceptRowSerializer(serializers.Serializer):
row.validate(commit=True, request=request)
if session := self.context.get('session', None):
+ # ensure current state is available
+ session.refresh_from_db()
session.check_complete()
return rows
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 34e46ce1b9..5ea4c9eaef 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -590,6 +590,21 @@ class Order(
"""Return the Address associated with this order."""
return self.address or self.company.primary_address
+ @property
+ def status_text(self):
+ """Return the text representation of the current status. This will consider any custom status."""
+ if self.get_custom_status() is not None:
+ from generic.states.custom import (
+ get_logical_value as get_custom_state_logical_value,
+ )
+
+ custom_status = get_custom_state_logical_value(
+ self.get_custom_status(), model=self._meta.model_name
+ )
+ return custom_status.label
+ else:
+ return self.status_class.label(self.get_status())
+
@classmethod
def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model."""
@@ -692,11 +707,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
help_text=_('Purchase order status'),
)
- @property
- def status_text(self):
- """Return the text representation of the status field."""
- return PurchaseOrderStatus.text(self.status)
-
supplier = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
@@ -1443,11 +1453,6 @@ class SalesOrder(TotalPriceMixin, Order):
help_text=_('Sales order status'),
)
- @property
- def status_text(self) -> str:
- """Return the text representation of the status field."""
- return SalesOrderStatus.text(self.status)
-
customer_reference = models.CharField(
max_length=64,
blank=True,
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index 899658046d..e77d803478 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -126,6 +126,14 @@ class AbstractOrderSerializer(
# status field cannot be set directly
status = serializers.IntegerField(read_only=True, label=_('Order Status'))
+ # can be set directly, but must be valid for the current order status
+ status_custom_key = serializers.IntegerField(
+ label=_('Custom Status Key'),
+ help_text=_('Update order status to a custom value for this logical value'),
+ allow_null=True,
+ default=None,
+ )
+
# Reference string is *required*
reference = serializers.CharField(required=True)
@@ -198,6 +206,31 @@ class AbstractOrderSerializer(
self.Meta.model.validate_reference_field(reference)
return reference
+ def validate_status_custom_key(self, value):
+ """Validate the status_custom_key field.
+
+ Ensure the custom status key is valid for the logical order status.
+ """
+ if value is None:
+ return value
+
+ from generic.states.custom import get_logical_value
+
+ if not isinstance(value, int):
+ raise ValidationError(_('Custom status key must be an integer'))
+
+ try:
+ custom_status = get_logical_value(
+ value, model=self.Meta.model._meta.model_name
+ )
+ except:
+ raise ValidationError(_('Invalid custom status key'))
+
+ if custom_status.logical_key is not self.instance.status:
+ raise ValidationError(_('Invalid custom status key for this order status'))
+
+ return value
+
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset."""
diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py
index 4e4347a92e..10b944f40f 100644
--- a/src/backend/InvenTree/order/test_api.py
+++ b/src/backend/InvenTree/order/test_api.py
@@ -6,6 +6,7 @@ import json
from datetime import date, datetime, timedelta
from typing import Optional
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
@@ -240,6 +241,77 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(data['pk'], 1)
self.assertEqual(data['description'], 'Ordering some screws')
+ def test_po_status_custom_key_options(self):
+ """Test that status_custom_key is exposed as writable in options."""
+ self.assignRole('purchase_order.add')
+
+ response = self.options(self.LIST_URL, expected_code=200)
+ post = response.data['actions']['POST']
+
+ self.assertIn('status_custom_key', post)
+ self.assertEqual(post['status_custom_key']['required'], False)
+ self.assertEqual(post['status_custom_key']['read_only'], False)
+
+ def test_po_status_custom_key_patch_valid(self):
+ """Test patching a valid custom status key for the current PO status."""
+ self.assignRole('purchase_order.change')
+
+ po = models.PurchaseOrder.objects.get(pk=1)
+ self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
+
+ custom_status = InvenTreeCustomUserStateModel.objects.create(
+ key=901,
+ name='PO Pending Custom',
+ label='PO Pending Custom',
+ color='secondary',
+ logical_key=PurchaseOrderStatus.PENDING.value,
+ model=ContentType.objects.get_for_model(models.PurchaseOrder),
+ reference_status='PurchaseOrderStatus',
+ )
+
+ url = reverse('api-po-detail', kwargs={'pk': po.pk})
+ response = self.patch(
+ url, {'status_custom_key': custom_status.key}, expected_code=200
+ )
+
+ self.assertEqual(response.data['status'], PurchaseOrderStatus.PENDING.value)
+ self.assertEqual(response.data['status_custom_key'], custom_status.key)
+
+ def test_po_status_custom_key_patch_invalid(self):
+ """Test patching an invalid custom status key for a PO."""
+ self.assignRole('purchase_order.change')
+
+ po = models.PurchaseOrder.objects.get(pk=1)
+ url = reverse('api-po-detail', kwargs={'pk': po.pk})
+
+ response = self.patch(url, {'status_custom_key': 999999}, expected_code=400)
+
+ self.assertIn('status_custom_key', response.data)
+
+ def test_po_status_custom_key_patch_wrong_logical_status(self):
+ """Test patching a custom key mapped to a different logical status."""
+ self.assignRole('purchase_order.change')
+
+ po = models.PurchaseOrder.objects.get(pk=1)
+ self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value)
+
+ custom_status = InvenTreeCustomUserStateModel.objects.create(
+ key=902,
+ name='PO Placed Custom',
+ label='PO Placed Custom',
+ color='secondary',
+ logical_key=PurchaseOrderStatus.PLACED.value,
+ model=ContentType.objects.get_for_model(models.PurchaseOrder),
+ reference_status='PurchaseOrderStatus',
+ )
+
+ url = reverse('api-po-detail', kwargs={'pk': po.pk})
+ response = self.patch(
+ url, {'status_custom_key': custom_status.key}, expected_code=400
+ )
+
+ self.assertIn('status_custom_key', response.data)
+
def test_po_reference(self):
"""Test that a reference with a too big / small reference is handled correctly."""
# get permissions
@@ -1950,6 +2022,63 @@ class SalesOrderTest(OrderTest):
reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail']
)
+ def test_so_custom_status_query_count(self):
+ """Test that listing SalesOrders with custom statuses does not cause N+1 queries.
+
+ Ensures that resolving the 'status_text' field for custom status values
+ is O(1) in database queries, not O(N) relative to the number of results.
+ """
+ so_content_type = ContentType.objects.get_for_model(models.SalesOrder)
+
+ logical_keys = [
+ SalesOrderStatus.PENDING.value,
+ SalesOrderStatus.IN_PROGRESS.value,
+ SalesOrderStatus.SHIPPED.value,
+ SalesOrderStatus.ON_HOLD.value,
+ SalesOrderStatus.COMPLETE.value,
+ SalesOrderStatus.CANCELLED.value,
+ SalesOrderStatus.PENDING.value,
+ SalesOrderStatus.IN_PROGRESS.value,
+ SalesOrderStatus.SHIPPED.value,
+ SalesOrderStatus.ON_HOLD.value,
+ ]
+
+ custom_statuses = [
+ InvenTreeCustomUserStateModel.objects.create(
+ key=2000 + i,
+ name=f'SoCustomStatus{i}',
+ label=f'SO Custom Status Label {i}',
+ color='secondary',
+ logical_key=logical_keys[i],
+ model=so_content_type,
+ reference_status='SalesOrderStatus',
+ )
+ for i in range(10)
+ ]
+
+ customer = Company.objects.filter(is_customer=True).first()
+ models.SalesOrder.objects.bulk_create([
+ models.SalesOrder(
+ customer=customer,
+ reference=f'SO-QTEST-{i}',
+ status=custom_statuses[i % 10].logical_key,
+ status_custom_key=custom_statuses[i % 10].key,
+ )
+ for i in range(100)
+ ])
+
+ for limit in [1, 5, 10, 25, 50, 100]:
+ response = self.get(
+ self.LIST_URL,
+ data={'limit': limit},
+ expected_code=200,
+ max_query_count=50,
+ )
+
+ for result in response.data['results']:
+ self.assertIn('status_text', result)
+ self.assertIsNotNone(result['status_text'])
+
class SalesOrderLineItemTest(OrderTest):
"""Tests for the SalesOrderLineItem API."""
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index f249332eb9..6b8d8e3459 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -783,7 +783,7 @@ export default function BuildDetail() {
? []
: [
,
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
index 99099bad1a..43f5e84301 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
@@ -543,7 +543,7 @@ export default function PurchaseOrderDetail() {
? []
: [
diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
index 67c1eba98f..b094826d7c 100644
--- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
@@ -390,7 +390,7 @@ export default function ReturnOrderDetail() {
? []
: [
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index ce5c4ac094..959ea726d1 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -605,7 +605,7 @@ export default function SalesOrderDetail() {
? []
: [
diff --git a/src/frontend/src/tables/settings/ImportSessionTable.tsx b/src/frontend/src/tables/settings/ImportSessionTable.tsx
index 39df628147..6dfd8101a4 100644
--- a/src/frontend/src/tables/settings/ImportSessionTable.tsx
+++ b/src/frontend/src/tables/settings/ImportSessionTable.tsx
@@ -18,6 +18,7 @@ import {
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
+import useStatusCodes from '../../hooks/UseStatusCodes';
import { useImporterState } from '../../states/ImporterState';
import { DateColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions, UserFilter } from '../Filter';
@@ -26,6 +27,9 @@ import { InvenTreeTable } from '../InvenTreeTable';
export default function ImportSessionTable() {
const table = useTable('importsession');
const openImporter = useImporterState((state) => state.openImporter);
+ const importSessionStatus = useStatusCodes({
+ modelType: ModelType.importsession
+ });
const [selectedSession, setSelectedSession] = useState(
undefined
@@ -82,13 +86,20 @@ export default function ImportSessionTable() {
sortable: false,
accessor: 'row_count',
title: t`Imported Rows`,
- render: (record: any) => (
-
- )
+ render: (record: any) =>
+ record.status == importSessionStatus.COMPLETE ? (
+
+ ) : (
+
+ )
}
];
}, []);
diff --git a/src/frontend/tests/pui_importing.spec.ts b/src/frontend/tests/pui_importing.spec.ts
index 026f7117c5..b7d5f64061 100644
--- a/src/frontend/tests/pui_importing.spec.ts
+++ b/src/frontend/tests/pui_importing.spec.ts
@@ -60,7 +60,7 @@ test('Importing - Admin Center', async ({ browser }) => {
await page.getByRole('button', { name: 'Close' }).click();
// Confirmation of full import success
- // await page.getByRole('cell', { name: '3 / 3' }).first().waitFor();
+ await page.getByRole('cell', { name: '3 / 3' }).first().waitFor();
// Manually delete records
await page.getByRole('checkbox', { name: 'Select all records' }).check();