2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-04 03:11:46 +00:00

Add "updated_at" field to Orders model (#11374)

* add "updated_at" field to PurchaseOrder model

* change to use abstract po instead

* add api filters

* add show, order and filter by po updated_at date to frontend

* add tests and increment api_version

* change updated_at to null by default

* never trust github conflict resolution

* bump docker image to python 3.14 (#11414)

* chore(deps): bump the dependencies group across 1 directory with 4 updates (#11416)

Bumps the dependencies group with 4 updates in the / directory: [depot/setup-action](https://github.com/depot/setup-action), [depot/build-push-action](https://github.com/depot/build-push-action), [anchore/sbom-action](https://github.com/anchore/sbom-action) and [actions/stale](https://github.com/actions/stale).


Updates `depot/setup-action` from 1.6.0 to 1.7.1
- [Release notes](https://github.com/depot/setup-action/releases)
- [Commits](b0b1ea4f69...15c09a5f77)

Updates `depot/build-push-action` from 1.16.2 to 1.17.0
- [Release notes](https://github.com/depot/build-push-action/releases)
- [Commits](9785b135c3...5f3b3c2e5a)

Updates `anchore/sbom-action` from 0.21.1 to 0.22.2
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](0b82b0b1a2...28d71544de)

Updates `actions/stale` from 10.1.1 to 10.2.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](997185467f...b5d41d4e1d)

---
updated-dependencies:
- dependency-name: depot/setup-action
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: depot/build-push-action
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: anchore/sbom-action
  dependency-version: 0.22.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: actions/stale
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* [UI] Copy cells expansion (#11410)

* Prevent copy button if copy value is null

* Add "link" columns to order tables

* Support copy for default column types

* Tweak padding to avoid flickering issues

* Refactor IPNColumn

* Adjust visual styling

* Copy for SKU and MPN columns

* Add more copy columns

* More tweaks

* Tweak playwright testing

* Further cleanup

* More copy cols

* Fix auto pricing overwriting manual purchase price #10846 (#11411)

* Fix auto pricing overwriting manual purchase price #10846

* Added entry to api_version.py

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>

* [UI] Default locale (#11412)

* [UI] Support default server language

* Handle faulty theme

* Add option for default language

* Improve language selection

* Brief docs entry

* Fix typo

* Fix yarn build

* Remove debug msg

* Fix calendar locale

* feat(backend): ensure restore of backups only works in correct enviroments (#11372)

* [FR] ensure restore of backups only works in correct enviroments
Fixes #11214

* update PR nbr

* fix wrong ty detection

* fix link

* ensure tracing does not enagage while running backup ops

* fix import

* remove debugging string

* add error codes

* add tests for backup and restore

* complete test for restore

* we do not need e2e on every matrix entry
there is no realy db dep here

* fix changelog format

* add flag to allow bypass

* update CHANGELOG.md

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JustusRijke <53965859+JustusRijke@users.noreply.github.com>
This commit is contained in:
Jack
2026-02-28 09:07:02 +11:00
committed by GitHub
parent 23d60bf05f
commit 3d9104e9a5
16 changed files with 385 additions and 18 deletions

View File

@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#11383](https://github.com/inventree/InvenTree/pull/11383) adds "exists_for_model_id", "exists_for_related_model", and "exists_for_related_model_id" filters to the ParameterTemplate API endpoint. These filters allow users to check for the existence of parameters associated with specific models or related models, improving the flexibility and usability of the API.
- [#10887](https://github.com/inventree/InvenTree/pull/10887) adds the ability to auto-allocate tracked items against specific build outputs. Currently, this will only allocate items where the serial number of the tracked item matches the serial number of the build output, but in future this may be extended to allow for more flexible allocation rules.
- [#11372](https://github.com/inventree/InvenTree/pull/11372) adds backup metadata setter and restore metadata validator functions to ensure common footguns are harder to trigger when using the backup and restore functionality.
- [#11374](https://github.com/inventree/InvenTree/pull/11374) adds `updated_at` field on purchase, sales and return orders.
### Changed

View File

@@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 459
INVENTREE_API_VERSION = 460
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v460 -> 2026-02-25 : https://github.com/inventree/InvenTree/pull/11374
- Adds "updated_at" field to PurchaseOrder, SalesOrder and ReturnOrder API endpoints
- Adds "updated_before" and "updated_after" date filters to all three order list endpoints
- Adds "updated_at" ordering option to all three order list endpoints
v459 -> 2026-02-23 : https://github.com/inventree/InvenTree/pull/11411
- Changed PurchaseOrderLine "auto_pricing" default value from true to false

View File

@@ -228,6 +228,14 @@ class OrderFilter(FilterSet):
label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
)
updated_before = InvenTreeDateFilter(
label=_('Updated Before'), field_name='updated_at', lookup_expr='lt'
)
updated_after = InvenTreeDateFilter(
label=_('Updated After'), field_name='updated_at', lookup_expr='gt'
)
min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
def filter_min_date(self, queryset, name, value):
@@ -420,6 +428,7 @@ class PurchaseOrderList(
'responsible',
'total_price',
'project_code',
'updated_at',
]
ordering = '-reference'
@@ -882,6 +891,7 @@ class SalesOrderList(
'shipment_date',
'total_price',
'project_code',
'updated_at',
]
search_fields = [
@@ -1549,6 +1559,7 @@ class ReturnOrderList(
'target_date',
'complete_date',
'project_code',
'updated_at',
]
search_fields = [

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.11 on 2026-02-19 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("order", "0114_purchaseorderextraline_project_code_and_more"),
]
operations = [
migrations.AddField(
model_name="purchaseorder",
name="updated_at",
field=models.DateTimeField(
blank=True,
null=True,
help_text="Timestamp of last update",
verbose_name="Updated At",
),
),
migrations.AddField(
model_name="returnorder",
name="updated_at",
field=models.DateTimeField(
blank=True,
null=True,
help_text="Timestamp of last update",
verbose_name="Updated At",
),
),
migrations.AddField(
model_name="salesorder",
name="updated_at",
field=models.DateTimeField(
blank=True,
null=True,
help_text="Timestamp of last update",
verbose_name="Updated At",
),
),
]

View File

@@ -9,7 +9,7 @@ from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import F, Q, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -331,6 +331,8 @@ class Order(
if not self.creation_date:
self.creation_date = InvenTree.helpers.current_date()
self.updated_at = InvenTree.helpers.current_time()
super().save(*args, **kwargs)
def check_locked(self, db: bool = False) -> bool:
@@ -498,6 +500,13 @@ class Order(
help_text=_('Date order was issued'),
)
updated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('Updated At'),
help_text=_('Timestamp of last update'),
)
responsible = models.ForeignKey(
UserModels.Owner,
on_delete=models.SET_NULL,
@@ -3072,3 +3081,43 @@ class ReturnOrderExtraLine(OrderExtraLine):
verbose_name=_('Order'),
help_text=_('Return Order'),
)
def _touch_order_updated_at(instance):
"""Bump updated_at on the parent order without triggering a full save."""
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
return
instance.order.__class__.objects.filter(pk=instance.order_id).update(
updated_at=InvenTree.helpers.current_time()
)
@receiver(post_save, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_save')
@receiver(
post_delete, sender=PurchaseOrderLineItem, dispatch_uid='po_lineitem_post_delete'
)
@receiver(
post_save, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_save'
)
@receiver(
post_delete, sender=PurchaseOrderExtraLine, dispatch_uid='po_extraline_post_delete'
)
@receiver(post_save, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_save')
@receiver(
post_delete, sender=SalesOrderLineItem, dispatch_uid='so_lineitem_post_delete'
)
@receiver(post_save, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_save')
@receiver(
post_delete, sender=SalesOrderExtraLine, dispatch_uid='so_extraline_post_delete'
)
@receiver(post_save, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_save')
@receiver(
post_delete, sender=ReturnOrderLineItem, dispatch_uid='ro_lineitem_post_delete'
)
@receiver(post_save, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_save')
@receiver(
post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete'
)
def update_order_on_lineitem_change(sender, instance, **kwargs):
"""Update parent order updated_at when any line item is saved or deleted."""
_touch_order_updated_at(instance)

View File

@@ -373,8 +373,14 @@ class PurchaseOrderSerializer(
'total_price',
'order_currency',
'destination',
'updated_at',
])
read_only_fields = ['issue_date', 'complete_date', 'creation_date']
read_only_fields = [
'issue_date',
'complete_date',
'creation_date',
'updated_at',
]
extra_kwargs = {
'supplier': {'required': True},
'order_currency': {'required': False},
@@ -1026,8 +1032,9 @@ class SalesOrderSerializer(
'shipments_count',
'completed_shipments_count',
'allocated_lines',
'updated_at',
])
read_only_fields = ['status', 'creation_date', 'shipment_date']
read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
extra_kwargs = {'order_currency': {'required': False}}
def skip_create_fields(self):
@@ -1918,8 +1925,9 @@ class ReturnOrderSerializer(
'customer_reference',
'order_currency',
'total_price',
'updated_at',
])
read_only_fields = ['creation_date']
read_only_fields = ['creation_date', 'updated_at']
def skip_create_fields(self):
"""Skip these fields when instantiating a new object."""

View File

@@ -25,7 +25,17 @@ from part.models import Part
from stock.models import StockItem, StockLocation
from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem
from .models import (
PurchaseOrder,
PurchaseOrderExtraLine,
PurchaseOrderLineItem,
ReturnOrder,
ReturnOrderExtraLine,
ReturnOrderLineItem,
SalesOrder,
SalesOrderExtraLine,
SalesOrderLineItem,
)
class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
@@ -369,7 +379,8 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
order=po,
part=sp_1,
quantity=3,
purchase_price=Money(1000, 'USD'), # "Unit price" should be $100USD
# "Unit price" should be $100USD
purchase_price=Money(1000, 'USD'),
)
# 13 x 0.1 = 1.3
@@ -569,3 +580,151 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class OrderUpdatedAtTest(TestCase):
"""Tests to verify that the updated_at field is correctly maintained on all order types."""
def setUp(self):
"""Set up objects for all three order types."""
self.supplier = Company.objects.filter(is_supplier=True).first()
self.customer = Company.objects.filter(is_customer=True).first()
self.po = PurchaseOrder.objects.create(
reference='PO-TEST-001', supplier=self.supplier
)
self.so = SalesOrder.objects.create(
reference='SO-TEST-001', customer=self.customer
)
self.ro = ReturnOrder.objects.create(
reference='RO-TEST-001', customer=self.customer
)
self.part = Part.objects.create(name='Test Part', description='Test Part')
self.stock_item = StockItem.objects.create(part=self.part, quantity=10)
def _refresh(self, instance):
"""Return a fresh copy of the instance from the database."""
return instance.__class__.objects.get(pk=instance.pk)
def test_updated_at_set_on_save(self):
"""updated_at should be populated after the order is saved."""
for instance in [self.po, self.so, self.ro]:
self.assertIsNotNone(self._refresh(instance).updated_at)
def test_updated_at_changes_on_save(self):
"""updated_at should advance when the order is saved again."""
for instance in [self.po, self.so, self.ro]:
original = self._refresh(instance).updated_at
instance.description = 'Updated description'
instance.save()
refreshed = self._refresh(instance)
self.assertGreaterEqual(refreshed.updated_at, original)
def test_updated_at_on_extra_line_add(self):
"""updated_at should advance on the parent order when an extra line is added."""
for instance, ExtraLine in [
(self.po, PurchaseOrderExtraLine),
(self.so, SalesOrderExtraLine),
(self.ro, ReturnOrderExtraLine),
]:
before = self._refresh(instance).updated_at
ExtraLine.objects.create(order=instance, quantity=1)
after = self._refresh(instance).updated_at
self.assertGreaterEqual(after, before)
def test_updated_at_on_extra_line_update(self):
"""updated_at should advance on the parent order when an extra line is updated."""
for instance, ExtraLine in [
(self.po, PurchaseOrderExtraLine),
(self.so, SalesOrderExtraLine),
(self.ro, ReturnOrderExtraLine),
]:
line = ExtraLine.objects.create(order=instance, quantity=1)
before = self._refresh(instance).updated_at
line.quantity = 5
line.save()
after = self._refresh(instance).updated_at
self.assertGreaterEqual(after, before)
def test_updated_at_on_extra_line_delete(self):
"""updated_at should advance on the parent order when an extra line is deleted."""
for instance, ExtraLine in [
(self.po, PurchaseOrderExtraLine),
(self.so, SalesOrderExtraLine),
(self.ro, ReturnOrderExtraLine),
]:
line = ExtraLine.objects.create(order=instance, quantity=1)
before = self._refresh(instance).updated_at
line.delete()
after = self._refresh(instance).updated_at
self.assertGreaterEqual(after, before)
def test_updated_at_on_line_item_add(self):
"""updated_at should advance on the parent order when a regular line item is added."""
before_po = self._refresh(self.po).updated_at
PurchaseOrderLineItem.objects.create(order=self.po, part=None, quantity=1)
self.assertGreaterEqual(self._refresh(self.po).updated_at, before_po)
before_so = self._refresh(self.so).updated_at
SalesOrderLineItem.objects.create(order=self.so, part=None, quantity=1)
self.assertGreaterEqual(self._refresh(self.so).updated_at, before_so)
before_ro = self._refresh(self.ro).updated_at
ReturnOrderLineItem.objects.create(
order=self.ro, item=self.stock_item, quantity=1
)
self.assertGreaterEqual(self._refresh(self.ro).updated_at, before_ro)
def test_updated_at_on_line_item_update(self):
"""updated_at should advance on the parent order when a regular line item is updated."""
po_line = PurchaseOrderLineItem.objects.create(
order=self.po, part=None, quantity=1
)
so_line = SalesOrderLineItem.objects.create(
order=self.so, part=None, quantity=1
)
ro_line = ReturnOrderLineItem.objects.create(
order=self.ro, item=self.stock_item, quantity=1
)
for instance, line in [
(self.po, po_line),
(self.so, so_line),
(self.ro, ro_line),
]:
before = self._refresh(instance).updated_at
line.quantity = 5
line.save()
self.assertGreaterEqual(self._refresh(instance).updated_at, before)
def test_updated_at_on_line_item_delete(self):
"""updated_at should advance on the parent order when a regular line item is deleted."""
po_line = PurchaseOrderLineItem.objects.create(
order=self.po, part=None, quantity=1
)
so_line = SalesOrderLineItem.objects.create(
order=self.so, part=None, quantity=1
)
ro_line = ReturnOrderLineItem.objects.create(
order=self.ro, item=self.stock_item, quantity=1
)
for instance, line in [
(self.po, po_line),
(self.so, so_line),
(self.ro, ro_line),
]:
before = self._refresh(instance).updated_at
line.delete()
self.assertGreaterEqual(self._refresh(instance).updated_at, before)

View File

@@ -54,10 +54,16 @@ export type DetailsField = {
type BadgeType = 'owner' | 'user' | 'group';
type ValueFormatterReturn = string | number | null | React.ReactNode;
type StringDetailField = {
type: 'string' | 'text' | 'date';
unit?: boolean;
};
type StringDetailField =
| {
type: 'string' | 'text';
unit?: boolean;
}
| {
type: 'date';
unit?: boolean;
showTime?: boolean;
};
type NumberDetailField = {
type: 'number';
@@ -260,7 +266,13 @@ function NameBadge({
}
function DateValue(props: Readonly<FieldProps>) {
return <Text size='sm'>{formatDate(props.field_value?.toString())}</Text>;
return (
<Text size='sm'>
{formatDate(props.field_value?.toString(), {
showTime: props.field_data?.showTime
})}
</Text>
);
}
// Return a formatted "number" value, with optional unit

View File

@@ -304,6 +304,15 @@ export default function PurchaseOrderDetail() {
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];

View File

@@ -282,6 +282,15 @@ export default function ReturnOrderDetail() {
label: t`Completion Date`,
copy: true,
hidden: !order.complete_date
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];

View File

@@ -273,6 +273,15 @@ export default function SalesOrderDetail() {
label: t`Completion Date`,
hidden: !order.shipment_date,
copy: true
},
{
type: 'date',
name: 'updated_at',
label: t`Last Updated`,
icon: 'calendar',
copy: true,
showTime: true,
hidden: !order.updated_at
}
];

View File

@@ -725,6 +725,16 @@ export function ShipmentDateColumn(props: TableColumnProps): TableColumn {
});
}
export function UpdatedAtColumn(props: TableColumnProps): TableColumn {
return DateColumn({
accessor: 'updated_at',
title: t`Updated`,
defaultVisible: false,
extra: { showTime: true },
...props
});
}
export function CurrencyColumn({
accessor,
title,

View File

@@ -286,6 +286,24 @@ export function CompletedAfterFilter(): TableFilter {
};
}
export function UpdatedAfterFilter(): TableFilter {
return {
name: 'updated_after',
label: t`Updated After`,
description: t`Show orders updated after this date`,
type: 'date'
};
}
export function UpdatedBeforeFilter(): TableFilter {
return {
name: 'updated_before',
label: t`Updated Before`,
description: t`Show orders updated before this date`,
type: 'date'
};
}
export function HasProjectCodeFilter(): TableFilter {
const globalSettings = useGlobalSettingsState.getState();
const enabled = globalSettings.isSet('PROJECT_CODES_ENABLED', true);

View File

@@ -25,7 +25,8 @@ import {
ResponsibleColumn,
StartDateColumn,
StatusColumn,
TargetDateColumn
TargetDateColumn,
UpdatedAtColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
@@ -45,7 +46,9 @@ import {
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
TargetDateBeforeFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -99,6 +102,8 @@ export function PurchaseOrderTable({
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
UpdatedBeforeFilter(),
UpdatedAfterFilter(),
ProjectCodeFilter(),
HasProjectCodeFilter(),
ResponsibleFilter(),
@@ -142,6 +147,9 @@ export function PurchaseOrderTable({
CompletionDateColumn({
accessor: 'complete_date'
}),
UpdatedAtColumn({
defaultVisible: false
}),
{
accessor: 'total_price',
title: t`Total Price`,

View File

@@ -25,7 +25,8 @@ import {
ResponsibleColumn,
StartDateColumn,
StatusColumn,
TargetDateColumn
TargetDateColumn,
UpdatedAtColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
@@ -46,7 +47,9 @@ import {
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
TargetDateBeforeFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -99,6 +102,8 @@ export function ReturnOrderTable({
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
UpdatedBeforeFilter(),
UpdatedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
@@ -146,6 +151,9 @@ export function ReturnOrderTable({
CompletionDateColumn({
accessor: 'complete_date'
}),
UpdatedAtColumn({
defaultVisible: false
}),
ResponsibleColumn({}),
{
accessor: 'total_price',

View File

@@ -27,7 +27,8 @@ import {
ShipmentDateColumn,
StartDateColumn,
StatusColumn,
TargetDateColumn
TargetDateColumn,
UpdatedAtColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
@@ -48,7 +49,9 @@ import {
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
TargetDateBeforeFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -97,6 +100,8 @@ export function SalesOrderTable({
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
UpdatedBeforeFilter(),
UpdatedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
@@ -182,6 +187,9 @@ export function SalesOrderTable({
}),
TargetDateColumn({}),
ShipmentDateColumn({}),
UpdatedAtColumn({
defaultVisible: false
}),
ResponsibleColumn({}),
{
accessor: 'total_price',