Merge commit '9e125be07e94e0997ce03fd905673c44aad81c00' into block-notes

This commit is contained in:
Oliver Walters 2026-06-18 01:05:01 +00:00
commit cec678f88b
18 changed files with 290 additions and 41 deletions

View File

@ -8,7 +8,7 @@ name: Frontend
on:
push:
branches-ignore: ["l10*"]
branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]

View File

@ -7,7 +7,7 @@ name: Import / Export
on:
push:
branches-ignore: ["l10*"]
branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]

View File

@ -4,7 +4,7 @@ name: QC
on:
push:
branches-ignore: ["l10*"]
branches-ignore: ["l10*", "dependabot/*", "backport/*"]
pull_request:
branches-ignore: ["l10*"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -783,7 +783,7 @@ export default function BuildDetail() {
? []
: [
<StatusRenderer
status={build.status_custom_key}
status={build.status_custom_key || build.status}
type={ModelType.build}
options={{ size: 'lg' }}
/>,

View File

@ -543,7 +543,7 @@ export default function PurchaseOrderDetail() {
? []
: [
<StatusRenderer
status={order.status_custom_key}
status={order.status_custom_key || order.status}
type={ModelType.purchaseorder}
options={{ size: 'lg' }}
/>

View File

@ -390,7 +390,7 @@ export default function ReturnOrderDetail() {
? []
: [
<StatusRenderer
status={order.status_custom_key}
status={order.status_custom_key || order.status}
type={ModelType.returnorder}
options={{ size: 'lg' }}
/>

View File

@ -605,7 +605,7 @@ export default function SalesOrderDetail() {
? []
: [
<StatusRenderer
status={order.status_custom_key}
status={order.status_custom_key || order.status}
type={ModelType.salesorder}
options={{ size: 'lg' }}
key={order.pk}

View File

@ -356,7 +356,7 @@ export default function TransferOrderDetail() {
? []
: [
<StatusRenderer
status={order.status_custom_key}
status={order.status_custom_key || order.status}
type={ModelType.transferorder}
options={{ size: 'lg' }}
/>

View File

@ -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<number | undefined>(
undefined
@ -82,13 +86,20 @@ export default function ImportSessionTable() {
sortable: false,
accessor: 'row_count',
title: t`Imported Rows`,
render: (record: any) => (
<ProgressBar
progressLabel={true}
value={record.completed_row_count}
maximum={record.row_count}
/>
)
render: (record: any) =>
record.status == importSessionStatus.COMPLETE ? (
<ProgressBar
progressLabel={true}
value={record.completed_row_count_history}
maximum={record.row_count_history}
/>
) : (
<ProgressBar
progressLabel={true}
value={record.completed_row_count}
maximum={record.row_count}
/>
)
}
];
}, []);

View File

@ -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();