mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 10:57:52 +00:00
[Refactor] Stock tracking audit (#13101)
* Add "event_type" and "notes" support to AuditLog model * Register history entries for stock items * record stock edited events as normal updates * Remove legacy tracking entry generation code * Updated unit tests for all stock event types * remove legacy endpoints * remove legacy tables from UI * Updated docs * Data migration * Remove old models * Update docs * Updated playwright tests * Fix broken links * Update CHANGELOG.md * Cache CID value * Fix migration tests * Increase query threshold for unit test * Fix bug and add regression test * mark migration as one-way * flush events in the correct order * improve prefetch * Adjust row rendering
This commit is contained in:
1 parent
2ee5764b3b
commit
c4280c2a03
64 files changed
+3869
-2150
No files matched your search
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Breaking Changes
|
||||
|
||||
- [#13034](https://github.com/inventree/InvenTree/pull/13034) moves pricing models into a new dedicated "pricing" model, and provides new API endpoints for managing pricing separately from other models.
|
||||
- [#13101](https://github.com/inventree/InvenTree/pull/13101) removes the StockTracking model and its associated API endpoints. Stock history tracking is now handled by the global audit log.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -19,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Removed
|
||||
|
||||
- [#13101](https://github.com/inventree/InvenTree/pull/13101) removes the StockTracking model and its associated API endpoints. Stock history tracking is now handled by the global audit log.
|
||||
|
||||
## 1.6.0 - 2026-10-18
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
@@ -36,7 +36,6 @@ To keep the audit log focused and performant, some changes are intentionally *no
|
||||
|
||||
- **Derived fields** - Internal or calculated fields (such as tree structure fields, cached pricing values and metadata) are excluded.
|
||||
- **Data import** - Changes made using the [data import](./data_import.md) tool are not recorded.
|
||||
- **Stock items** - Changes to stock items are tracked separately using [stock tracking](../stock/tracking.md).
|
||||
- **Unsupported models** - Only the models listed below are audited.
|
||||
|
||||
### Audited Models
|
||||
@@ -45,6 +44,21 @@ The following models are currently audited. Where a model has a *parent*, change
|
||||
|
||||
{{ audited_models() }}
|
||||
|
||||
### Events
|
||||
|
||||
Some models record named *events* in the audit log, in addition to field changes. An event describes *what happened* to an item (for example, an operation which affects multiple fields, or multiple items), and may include:
|
||||
|
||||
- **Event type** - The type of event (displayed in place of the action in the audit log table)
|
||||
- **Notes** - Optional notes provided by the user when the event was recorded
|
||||
- **Deltas** - Additional information about the event, such as quantities or references to related objects. References to related objects are recorded as they were at the time of the event.
|
||||
|
||||
An event may be recorded even if no fields of the item were changed.
|
||||
|
||||
For example, stock operations (such as moving, counting or shipping stock) are recorded as [stock tracking](../stock/tracking.md) events against each affected stock item.
|
||||
|
||||
!!! warning "Disabling the Audit Log"
|
||||
Events are recorded in the audit log, and are subject to the same settings as all other audit entries. If the audit log is disabled, events are **not** recorded - this includes **all stock tracking history**.
|
||||
|
||||
### Viewing Change History
|
||||
|
||||
#### Detail Pages
|
||||
@@ -60,9 +74,10 @@ For example, the history panel for a purchase order shows changes made to the or
|
||||
The *History* panel is only displayed if **all** of the following conditions are met:
|
||||
|
||||
- The audit log is enabled (via the `Enable Audit Log` system setting)
|
||||
- The user is a *staff* user
|
||||
- The user has enabled the *Show Change History* user setting
|
||||
|
||||
The entries displayed in the *History* panel are filtered by the permissions of the user (see [Permissions](#permissions) below).
|
||||
|
||||
{{ image("concepts/audit_user_setting.png", "Show change history setting") }}
|
||||
|
||||
#### Admin Center
|
||||
@@ -79,10 +94,12 @@ The audit log table can be filtered by action, user, date range and model type:
|
||||
|
||||
Access to the audit log is restricted:
|
||||
|
||||
- The audit log (including the `/api/audit/` API endpoint) is only available to *staff* users.
|
||||
- Audit entries are additionally filtered by the user's [role permissions](../settings/permissions.md). A user can only view audit entries for models which they have permission to view.
|
||||
- Audit entries are filtered by the user's [role permissions](../settings/permissions.md). A user can only view audit entries for models which they have permission to view.
|
||||
- Some models (e.g. attachments and parameters) require the user to also have permission to view the *parent* model.
|
||||
- The history of some models (e.g. global settings) is only available to *staff* users.
|
||||
- The remote IP address associated with each entry is only visible to staff users.
|
||||
- References to related objects (recorded against [events](#events)) are hidden if the user does not have permission to view the related model.
|
||||
- The *Audit Log* panel in the Admin Center is only available to staff users.
|
||||
|
||||
### Data Retention
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ The following options are available when creating a new build output:
|
||||
|
||||
### Specifying Serial Numbers
|
||||
|
||||
Refer to the [serial number generation guide](../stock/tracking.md#generating-serial-numbers) for further information on serial number input.
|
||||
Refer to the [serial number generation guide](../stock/traceability.md#generating-serial-numbers) for further information on serial number input.
|
||||
|
||||
### Creating Outputs Without Serial Numbers
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ Validation of the Part IPN (Internal Part Number) field is exposed to custom plu
|
||||
|
||||
### Batch Codes
|
||||
|
||||
[Batch codes](../../stock/tracking.md#batch-codes) can be generated and/or validated by custom plugins.
|
||||
[Batch codes](../../stock/traceability.md#batch-codes) can be generated and/or validated by custom plugins.
|
||||
|
||||
#### Validate Batch Code
|
||||
|
||||
@@ -184,7 +184,7 @@ The `generate_batch_code` method can be implemented to generate a new batch code
|
||||
|
||||
Requirements for serial numbers can vary greatly depending on the application. Rather than attempting to provide a "one size fits all" serial number implementation, InvenTree allows custom serial number schemes to be implemented via plugins.
|
||||
|
||||
The default InvenTree [serial numbering system](../../stock/tracking.md#serial-numbers) uses a simple algorithm to validate and increment serial numbers. More complex behaviors can be implemented using the `ValidationMixin` plugin class and the following custom methods:
|
||||
The default InvenTree [serial numbering system](../../stock/traceability.md#serial-numbers) uses a simple algorithm to validate and increment serial numbers. More complex behaviors can be implemented using the `ValidationMixin` plugin class and the following custom methods:
|
||||
|
||||
#### Serial Number Validation
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ Receiving a line item performs the following actions on the underlying stock ite
|
||||
* Sets the stock item status (defaulting to *Quarantined*)
|
||||
* Removes the *Customer* reference from the stock item
|
||||
* Clears any outstanding sales order allocations against the stock item
|
||||
* Adds a tracking entry recording the return
|
||||
* Records a [stock tracking](../stock/tracking.md) event for the return
|
||||
|
||||
!!! info "Partial Returns"
|
||||
If a customer returns less than the full quantity of a non-serialized stock item, InvenTree automatically splits the returned quantity into a new stock item, leaving the remainder of the original stock item untouched (e.g. still recorded as sold to the customer).
|
||||
|
||||
@@ -12,7 +12,7 @@ Stock adjustments can be accessed in any stock items table using the "Stock Opti
|
||||
|
||||
### Move Stock
|
||||
|
||||
Multiple stock items can be moved to a new location in a single operation. Each item is moved to the selected location, and a stock tracking entry is added to the stock item history.
|
||||
Multiple stock items can be moved to a new location in a single operation. Each item is moved to the selected location, and a [stock tracking](./tracking.md) event is recorded against each stock item.
|
||||
|
||||
{{ image("stock/stock_move.png", "Stock Move") }}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ The original stock item is never deleted as a result of disassembly - its quanti
|
||||
If the stock item being disassembled has other stock items [installed](../manufacturing/allocate.md#allocating-tracked-stock) within it (for example, tracked components that were installed during a build order), these installed items **must** be accounted for during disassembly:
|
||||
|
||||
- Each installed item is matched against a BOM line item, based on the component part (including any [substitute](../manufacturing/bom.md#substitute-bom-line-items) or [variant](../part/index.md#assembly) parts allowed for that line)
|
||||
- Matched installed items are *uninstalled* directly, rather than being discarded and re-created - this preserves the original stock item, including its own tracking history, batch code, and purchase price
|
||||
- Matched installed items are *uninstalled* directly, rather than being discarded and re-created - this preserves the original stock item, including its own stock tracking history, batch code, and purchase price
|
||||
- The quantity requested for the matching BOM line is reduced by the quantity already covered by the installed item(s). A new stock item is only created for any remaining quantity - if the installed items fully cover the required quantity, no new stock item is created for that line
|
||||
- Any installed item which does *not* match one of the selected BOM lines is still uninstalled (it cannot be left "installed" inside a smaller or non-existent parent), but its quantity is **not** subtracted from any line
|
||||
|
||||
@@ -65,10 +65,10 @@ If the original stock item has a recorded purchase price, and no explicit *Unit
|
||||
|
||||
Disassembling a stock item generates a full audit trail:
|
||||
|
||||
- A `Disassembled into components` entry is added to the tracking history of the original stock item
|
||||
- Each newly created component stock item receives a `Created from disassembly` tracking entry, referencing the original stock item
|
||||
- A `Disassembled into components` event is added to the [stock tracking history](./tracking.md) of the original stock item
|
||||
- Each newly created component stock item records a `Created from disassembly` event, referencing the original stock item
|
||||
- The *batch code* and source *purchase order* of the original stock item are copied directly to each generated component
|
||||
- If the original stock item was generated by a build order, that build order cannot be directly copied to the new component (since the component was not actually built by that order) - instead, it is recorded as a reference within the `Created from disassembly` tracking entry
|
||||
- If the original stock item was generated by a build order, that build order cannot be directly copied to the new component (since the component was not actually built by that order) - instead, it is recorded as a reference within the `Created from disassembly` event
|
||||
|
||||
### Purchasing Bundled Items
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ Refer to the [traceability](./traceability.md) page for more information on how
|
||||
|
||||
## Stock Tracking
|
||||
|
||||
Every time a *Stock Item* is adjusted, a *Stock Tracking* entry is automatically created. This ensures a complete history of the *Stock Item* is maintained as long as the item is in the system.
|
||||
Every time a *Stock Item* is adjusted, a stock tracking event is automatically recorded in the [audit log](../concepts/audit.md). This ensures a complete history of the *Stock Item* is maintained.
|
||||
|
||||
Each stock tracking historical item records the user who performed the action. [Read more about stock tracking here](./tracking.md).
|
||||
Each stock tracking event records the user who performed the action. [Read more about stock tracking here](./tracking.md).
|
||||
|
||||
## Stock Locations
|
||||
|
||||
|
||||
+27
-20
@@ -4,47 +4,56 @@ title: Stock Tracking
|
||||
|
||||
## Stock Tracking
|
||||
|
||||
Stock tracking entries record the history of stock item adjustments, including the user who performed the action, the date of the action, and the quantity change. This allows users to maintain a complete history of stock item movements and adjustments over time.
|
||||
Stock tracking records the history of stock item adjustments, including the user who performed the action, the date of the action, and the changes made to the stock item (e.g. quantity or location). This allows users to maintain a complete history of stock item movements and adjustments over time.
|
||||
|
||||
Stock tracking history is recorded in the [audit log](../concepts/audit.md) - each stock operation is recorded as a named [event](../concepts/audit.md#events) against each affected stock item.
|
||||
|
||||
### Tracking Events
|
||||
|
||||
Stock tracking entries are created automatically whenever a stock item is adjusted, either through manual adjustments or through automated processes such as order fulfillment or build completion.
|
||||
Stock tracking events are recorded automatically whenever a stock item is adjusted, either through manual adjustments or through automated processes such as order fulfillment or build completion.
|
||||
|
||||
Some examples of events that may trigger stock tracking entries include:
|
||||
Some examples of operations that record stock tracking events include:
|
||||
|
||||
- Manual stock adjustments (e.g. correcting inventory counts)
|
||||
- Creation of new stock items (e.g. receiving new inventory)
|
||||
- Allocation of stock items to orders (e.g. shipping items against sales orders)
|
||||
- Consumption of stock items during build processes (e.g. using items to complete a build order)
|
||||
|
||||
### Tracking Entry Types
|
||||
### Tracking Event Types
|
||||
|
||||
Each stock tracking entry records a specific *type*, indicating which event triggered it:
|
||||
Each stock tracking event records a specific *type*, indicating which operation triggered it:
|
||||
|
||||
{{ statuscodes("StockHistoryCode") }}
|
||||
|
||||
!!! info "Legacy Event Types"
|
||||
The *Legacy stock tracking entry*, *Edited stock item* and *Assigned serial number* event types are only present in stock history recorded by older versions of InvenTree. Edits to a stock item (e.g. changing the status) are now recorded as regular updates in the audit log, and serial numbers are recorded as part of the creation of the stock item.
|
||||
|
||||
## Viewing Stock Tracking History
|
||||
|
||||
There are multiple ways to view the stock tracking history for a particular stock item or part via the user interface.
|
||||
Stock tracking events are recorded in the [audit log](../concepts/audit.md), along with any other changes made to a stock item. Each event records the user who performed the action, any notes provided, and additional information about the event (such as the quantity added or removed, or the order which the item was received against).
|
||||
|
||||
### Stock Item Tracking History
|
||||
!!! warning "Audit Log Required"
|
||||
Stock tracking history is recorded in the audit log. If the audit log is disabled (via the `Enable Audit Log` system setting), **no stock tracking history is recorded**. Refer to the [audit log documentation](../concepts/audit.md#settings) for more information.
|
||||
|
||||
The stock tracking history for a particular stock item can be viewed on the *Stock Item Detail* page, under the *Stock Tracking* tab:
|
||||
### Stock Item History
|
||||
|
||||
{{ image("stock/stock_item_tracking_history.png", title="Stock tracking tab") }}
|
||||
The stock tracking history for a particular stock item is displayed in the *History* panel of the *Stock Item Detail* page:
|
||||
|
||||
This view displays all tracking entries associated with the particular stock item.
|
||||
{{ image("stock/stock_item_history.png", "Stock item history") }}
|
||||
|
||||
### Part Tracking History
|
||||
Each entry displays the type of event, along with any changes made to the stock item, and any additional information recorded against the event (such as notes, quantities, or links to related orders and stock items).
|
||||
|
||||
Additionally, the stock tracking history for a particular part can be viewed on the *Part Detail* page, under the *Stock History* tab:
|
||||
### Part History
|
||||
|
||||
{{ image("stock/part_tracking_history.png", title="Part stock tracking history") }}
|
||||
The history of all stock items for a particular part is displayed in the *History* panel of the *Part Detail* page, along with any changes made to the part itself:
|
||||
|
||||
This view displays all tracking entries associated with any stock item linked to the particular part.
|
||||
{{ image("stock/part_stock_history.png", "Part stock history") }}
|
||||
|
||||
!!! info "Show Change History"
|
||||
The *History* panel is only displayed if the *Show Change History* user setting is enabled. Refer to the [audit log documentation](../concepts/audit.md#viewing-change-history) for more information.
|
||||
|
||||
!!! info "Deleted Stock Items"
|
||||
Even if a stock item is deleted from the system, the associated stock tracking entries are retained for historical reference. They will be visible in the part tracking history, but not in the stock item tracking history (as the stock item itself has been deleted).
|
||||
Even if a stock item is deleted from the system, the associated history is retained for historical reference. It is visible in the history of the part, but not in the history of the stock item (as the stock item itself has been deleted).
|
||||
|
||||
## Installed Stock Items
|
||||
|
||||
@@ -73,9 +82,7 @@ Additionally, installed items are automatically uninstalled when the parent item
|
||||
|
||||
## Stock Tracking Settings
|
||||
|
||||
There are a number of configuration options available for controlling the behavior of stock tracking functionality in the [system settings view](../settings/global.md):
|
||||
Stock tracking history is recorded in the audit log, and is subject to the [audit log settings](../concepts/audit.md#settings). For example, old stock tracking history can be automatically removed using the {{ globalsetting("AUDIT_LOG_DELETE_OLD_ENTRIES", short=True) }} setting.
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("STOCK_TRACKING_DELETE_OLD_ENTRIES") }}
|
||||
{{ globalsetting("STOCK_TRACKING_DELETE_DAYS") }}
|
||||
!!! info "Audit Log Retention"
|
||||
The *Delete Old Stock Tracking Entries* and *Stock Tracking Deletion Interval* settings have been removed. The retention of stock tracking history is now controlled by the audit log settings.
|
||||
@@ -1,11 +1,20 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 565
|
||||
INVENTREE_API_VERSION = 566
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v566 -> 2026-10-25 : https://github.com/inventree/InvenTree/pull/13101
|
||||
- Audit log API (/api/audit/) is available to all authenticated users (entries are filtered by model permissions)
|
||||
- Adds "event_type", "event_label", "notes" and "deltas" fields to the audit log API
|
||||
- Adds "event_type" filter to the audit log API
|
||||
- Stock item history is recorded in the audit log (StockItem events, with event_type values from StockHistoryCode)
|
||||
- Breaking: Removes the stock tracking API endpoints (/api/stock/track/, /api/stock/track/<id>/, /api/stock/track/status/) - use /api/audit/ instead
|
||||
- Breaking: Removes the "tracking_items" field from the StockItem API
|
||||
- Breaking: Removes the "copy_history" option when duplicating a StockItem
|
||||
|
||||
v565 -> 2026-10-24 : https://github.com/inventree/InvenTree/pull/13069
|
||||
- Adds read-only audit log API endpoints (/api/audit/)
|
||||
- OPTIONS metadata includes a "filters" entry, listing the available choices for choice-based list filters
|
||||
|
||||
@@ -117,6 +117,7 @@ class InvenTreeConfig(AppConfig):
|
||||
'label.tasks.cleanup_old_label_outputs',
|
||||
'report.tasks.cleanup_old_report_outputs',
|
||||
'stock.tasks.delete_old_stock_items',
|
||||
'stock.tasks.delete_old_stock_tracking',
|
||||
]
|
||||
|
||||
try:
|
||||
|
||||
@@ -31,7 +31,7 @@ def _suspend_auto_now(model):
|
||||
use the fixture's own value. bulk_create() has no equivalent of that
|
||||
raw=True path - it always calls Field.pre_save(), which unconditionally
|
||||
stamps such fields with timezone.now(), discarding whatever value the
|
||||
fixture provided (e.g. stock.StockItemTracking.date). Clearing the flags
|
||||
fixture provided (e.g. stock.StockItem.creation_date). Clearing the flags
|
||||
for the duration of the bulk_create() call restores the raw=True
|
||||
behaviour for this model.
|
||||
"""
|
||||
@@ -65,7 +65,7 @@ class Command(LoadDataCommand):
|
||||
auto_now / auto_now_add fields are a partial exception: they are
|
||||
temporarily disabled around the bulk_create() call (see
|
||||
_suspend_auto_now()) so that fixture-provided values for fields such as
|
||||
stock.StockItemTracking.date survive the import, matching what a
|
||||
stock.StockItem.creation_date survive the import, matching what a
|
||||
normal, non-bulk fixture load does via raw=True.
|
||||
- Multi-table inheritance is not supported by bulk_create() and will fail
|
||||
loudly rather than being silently mishandled. Natural-key foreign key /
|
||||
@@ -74,7 +74,7 @@ class Command(LoadDataCommand):
|
||||
sped up by caching each resolved natural key for the life of the command
|
||||
(see _cached_natural_keys()), since 'export_records' uses
|
||||
--natural-foreign and a large fixture can have many rows referencing the
|
||||
same handful of natural-keyed objects (e.g. stock.StockItemTracking.user
|
||||
same handful of natural-keyed objects (e.g. audit.AuditLogEntry.actor
|
||||
-> auth.User) - without caching, each one costs a separate DB query.
|
||||
|
||||
Based on the django forum thread:
|
||||
@@ -135,7 +135,7 @@ class Command(LoadDataCommand):
|
||||
Django's deserializer (deserialize_fk_value) issues a fresh DB query
|
||||
every time it resolves a natural-key FK reference, with no caching of
|
||||
its own. A fixture with many rows referencing the same handful of
|
||||
natural-keyed objects (e.g. thousands of stock.StockItemTracking rows
|
||||
natural-keyed objects (e.g. thousands of audit.AuditLogEntry rows
|
||||
all pointing at a few auth.User accounts) would otherwise cost one
|
||||
query per row instead of one query per distinct value.
|
||||
"""
|
||||
|
||||
@@ -547,6 +547,8 @@ class InvenTreeAuditMixin:
|
||||
AUDIT_VIEW_RESTRICTION: Restrict reading audit history to 'staff' or 'superuser' users (or None)
|
||||
AUDIT_REQUIRE_PARENT_PERMISSION: Entries are only visible to users who can view the *parent* model
|
||||
(for models whose access is determined by the linked object, e.g. attachments)
|
||||
AUDIT_EVENT_TYPES: Enumeration of named event types which can be recorded against this model
|
||||
(e.g. StockHistoryCode) - see audit.helpers.audit_event
|
||||
"""
|
||||
|
||||
AUDIT_INCLUDE_FIELDS: tuple[str, ...] = ()
|
||||
@@ -556,6 +558,7 @@ class InvenTreeAuditMixin:
|
||||
AUDIT_PARENT_FIELD: Optional[str] = None
|
||||
AUDIT_VIEW_RESTRICTION: Optional[str] = None
|
||||
AUDIT_REQUIRE_PARENT_PERMISSION: bool = False
|
||||
AUDIT_EVENT_TYPES: Optional[type] = None
|
||||
|
||||
def get_audit_parent(self):
|
||||
"""Return the "parent" object for audit log entries against this instance.
|
||||
|
||||
@@ -1232,7 +1232,7 @@ def apply_duplicate_copy_options(
|
||||
at each call site rather than a new copy-pasted `if duplicate.get(...): instance.copy_..._
|
||||
from(...)` block. Any duplicate flag whose target method doesn't follow the
|
||||
copy_<x>_from() naming convention (e.g. Part's copy_bom/copy_image/copy_tests, or
|
||||
StockItem's copy_history/copy_tests) must still be handled separately by the caller.
|
||||
StockItem's copy_tests) must still be handled separately by the caller.
|
||||
|
||||
Arguments:
|
||||
instance: The newly created instance to copy data onto
|
||||
|
||||
@@ -346,7 +346,7 @@ def batch_offload_tasks():
|
||||
Nesting is not supported: a nested batch_offload_tasks() call reuses the outer batch,
|
||||
and only the outermost call schedules a flush.
|
||||
|
||||
This mirrors plugin.base.event.events.batch_events() and stock.models.batch_tracking_entries()
|
||||
This mirrors plugin.base.event.events.batch_events() and audit.helpers.write_batched()
|
||||
- see batch_events()'s docstring for the reasoning behind the on-commit flush and the
|
||||
context-local (rather than parameter-based) design.
|
||||
|
||||
|
||||
@@ -165,9 +165,8 @@ class CommandTestCase(TestCase):
|
||||
"""bulk_create() must not overwrite fixture values for auto_now_add fields.
|
||||
|
||||
Covers both an auto_now_add DateTimeField (BarcodeScanResult.timestamp,
|
||||
StockItem.creation_date, StockItemTracking.date) and an auto_now_add
|
||||
DateField (Part.creation_date) - StockItem/StockItemTracking are also
|
||||
the models the bug was originally reported against.
|
||||
StockItem.creation_date) and an auto_now_add DateField (Part.creation_date)
|
||||
- StockItem is also the model the bug was originally reported against.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
@@ -176,8 +175,7 @@ class CommandTestCase(TestCase):
|
||||
|
||||
from common.models import BarcodeScanResult
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockItemTracking
|
||||
from stock.status_codes import StockHistoryCode
|
||||
from stock.models import StockItem
|
||||
|
||||
# JSON fixtures only round-trip datetimes to millisecond precision
|
||||
# (DjangoJSONEncoder truncates microseconds) - use a value already at
|
||||
@@ -207,27 +205,13 @@ class CommandTestCase(TestCase):
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.creation_date, original_timestamp)
|
||||
|
||||
tracking = StockItemTracking.objects.create(
|
||||
item=item, tracking_type=StockHistoryCode.CREATED
|
||||
)
|
||||
tracking.date = original_timestamp
|
||||
tracking.save()
|
||||
tracking.refresh_from_db()
|
||||
self.assertEqual(tracking.date, original_timestamp)
|
||||
|
||||
pks = {
|
||||
'barcode': entry.pk,
|
||||
'part': part.pk,
|
||||
'item': item.pk,
|
||||
'tracking': tracking.pk,
|
||||
}
|
||||
pks = {'barcode': entry.pk, 'part': part.pk, 'item': item.pk}
|
||||
|
||||
# Serialize parent-before-child, so bulkloaddata's per-model
|
||||
# bulk_create() calls happen in an order that satisfies FK constraints.
|
||||
data = serializers.serialize('json', [entry, part, item, tracking])
|
||||
data = serializers.serialize('json', [entry, part, item])
|
||||
|
||||
# Use queryset deletes - Part.delete() refuses to delete an active part
|
||||
StockItemTracking.objects.filter(pk=tracking.pk).delete()
|
||||
StockItem.objects.filter(pk=item.pk).delete()
|
||||
Part.objects.filter(pk=part.pk).delete()
|
||||
BarcodeScanResult.objects.filter(pk=entry.pk).delete()
|
||||
@@ -247,10 +231,7 @@ class CommandTestCase(TestCase):
|
||||
reloaded_item = StockItem.objects.get(pk=pks['item'])
|
||||
self.assertEqual(reloaded_item.creation_date, original_timestamp)
|
||||
|
||||
reloaded_tracking = StockItemTracking.objects.get(pk=pks['tracking'])
|
||||
self.assertEqual(reloaded_tracking.date, original_timestamp)
|
||||
finally:
|
||||
StockItemTracking.objects.filter(pk=pks['tracking']).delete()
|
||||
StockItem.objects.filter(pk=pks['item']).delete()
|
||||
Part.objects.filter(pk=pks['part']).delete()
|
||||
BarcodeScanResult.objects.filter(pk=pks['barcode']).delete()
|
||||
|
||||
@@ -210,6 +210,48 @@ def findOffloadedEvent(
|
||||
)
|
||||
|
||||
|
||||
def stock_history(item, **filters):
|
||||
"""Return the audit log entries (stock history) for a StockItem, oldest first.
|
||||
|
||||
Arguments:
|
||||
item: The StockItem instance (or primary key) - the item may have since been deleted
|
||||
**filters: Additional filters to apply to the queryset (e.g. event_type=...)
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
from stock.models import StockItem
|
||||
|
||||
pk = getattr(item, 'pk', item)
|
||||
|
||||
return AuditLogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(StockItem),
|
||||
object_pk=str(pk),
|
||||
**filters,
|
||||
).order_by('pk')
|
||||
|
||||
|
||||
def stock_history_count(**filters) -> int:
|
||||
"""Return the total number of audit log entries recorded for all StockItems.
|
||||
|
||||
Arguments:
|
||||
**filters: Additional filters to apply (e.g. event_type=...)
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
from stock.models import StockItem
|
||||
|
||||
return AuditLogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(StockItem), **filters
|
||||
).count()
|
||||
|
||||
|
||||
def stock_events(item, **filters):
|
||||
"""Return the named stock tracking events recorded for a StockItem, oldest first."""
|
||||
return stock_history(item, event_type__isnull=False, **filters)
|
||||
|
||||
|
||||
class UserMixin:
|
||||
"""Mixin to setup a user and login for tests.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from audit.permissions import (
|
||||
from common.filters import determine_content_type
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers_api import InvenTreeApiRouter
|
||||
from InvenTree.permissions import IsAdminOrAdminScope
|
||||
from InvenTree.permissions import IsAuthenticatedOrReadScope
|
||||
|
||||
|
||||
def audited_model_type_choices(user=None) -> list[tuple[str, str]]:
|
||||
@@ -83,7 +83,7 @@ class AuditLogFilter(rest_filters.FilterSet):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = AuditLogEntry
|
||||
fields = ['action', 'actor', 'cid']
|
||||
fields = ['action', 'actor', 'cid', 'event_type']
|
||||
|
||||
model_type = ModelTypeChoiceFilter(
|
||||
choices=audited_model_type_choices,
|
||||
@@ -178,9 +178,9 @@ class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
serializer_class = audit.serializers.AuditLogEntrySerializer
|
||||
|
||||
# The audit log is only available to staff users.
|
||||
# Entries are *additionally* filtered by the user's permissions on each audited model.
|
||||
permission_classes = [IsAdminOrAdminScope]
|
||||
# Entries are filtered by the user's permissions on each audited model
|
||||
# (some models are further restricted to staff users, see audit.registry.AUDIT_VIEW_RESTRICTIONS)
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
filterset_class = AuditLogFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
@@ -18,6 +18,9 @@ _audit_request = ContextVar('inventree_audit_request', default=None)
|
||||
_audit_cid = ContextVar('inventree_audit_cid', default=None)
|
||||
_audit_source = ContextVar('inventree_audit_source', default=None)
|
||||
_audit_batch = ContextVar('inventree_audit_batch', default=None)
|
||||
_audit_batch_cid = ContextVar('inventree_audit_batch_cid', default=None)
|
||||
_audit_events = ContextVar('inventree_audit_events', default=None)
|
||||
_audit_flush_event = ContextVar('inventree_audit_flush_event', default=None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -26,6 +29,9 @@ def audit_batch():
|
||||
|
||||
Nested contexts share the outermost list.
|
||||
|
||||
The correlation ID is resolved once for the batch (see get_cid),
|
||||
as resolving it for every entry is expensive for large batches.
|
||||
|
||||
Yields:
|
||||
The list of collected entries, which the caller is responsible for writing
|
||||
(or None for a nested context, as there is nothing to write).
|
||||
@@ -36,10 +42,12 @@ def audit_batch():
|
||||
|
||||
entries = []
|
||||
token = _audit_batch.set(entries)
|
||||
cid_token = _audit_batch_cid.set(resolve_cid())
|
||||
|
||||
try:
|
||||
yield entries
|
||||
finally:
|
||||
_audit_batch_cid.reset(cid_token)
|
||||
_audit_batch.reset(token)
|
||||
|
||||
|
||||
@@ -48,6 +56,151 @@ def get_batch() -> Optional[list]:
|
||||
return _audit_batch.get()
|
||||
|
||||
|
||||
class PendingEvent:
|
||||
"""A named event which is waiting to be attached to an audit entry (see audit.helpers.audit_event)."""
|
||||
|
||||
def __init__(self, instance, event_type: int, notes=None, deltas=None, actor=None):
|
||||
"""Initialize the pending event."""
|
||||
self.instance = instance
|
||||
self.event_type = event_type
|
||||
self.notes = notes
|
||||
self.deltas = deltas
|
||||
self.actor = actor
|
||||
self.consumed = False
|
||||
|
||||
|
||||
def instance_key(instance) -> Optional[tuple]:
|
||||
"""Return a key which identifies the database row of an instance (or None if unsaved)."""
|
||||
if instance.pk is None:
|
||||
return None
|
||||
|
||||
return (type(instance), instance.pk)
|
||||
|
||||
|
||||
class EventRegistry:
|
||||
"""The pending events within an audit_event() context.
|
||||
|
||||
Events are indexed by instance (identity), and by database row (model and primary key),
|
||||
so that matching events can be found efficiently for large numbers of events.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty registry."""
|
||||
self.counter = 0
|
||||
self.by_id: dict[int, list] = {}
|
||||
self.by_key: dict[tuple, list] = {}
|
||||
|
||||
def add(self, event: PendingEvent) -> None:
|
||||
"""Add an event to the registry."""
|
||||
self.counter += 1
|
||||
event.order = self.counter
|
||||
|
||||
self.by_id.setdefault(id(event.instance), []).append(event)
|
||||
|
||||
if key := instance_key(event.instance):
|
||||
self.by_key.setdefault(key, []).append(event)
|
||||
|
||||
def remove(self, event: PendingEvent) -> None:
|
||||
"""Remove an event from the registry."""
|
||||
for index, key in [
|
||||
(self.by_id, id(event.instance)),
|
||||
(self.by_key, instance_key(event.instance)),
|
||||
]:
|
||||
if key is not None and event in (events := index.get(key, [])):
|
||||
events.remove(event)
|
||||
|
||||
if not events:
|
||||
del index[key]
|
||||
|
||||
def find(self, instance) -> Optional[PendingEvent]:
|
||||
"""Return the first (unconsumed) pending event for the provided instance.
|
||||
|
||||
Instances are matched by identity, or by model and primary key
|
||||
(e.g. if the same database row is saved via a different instance).
|
||||
"""
|
||||
candidates = list(self.by_id.get(id(instance), []))
|
||||
|
||||
if key := instance_key(instance):
|
||||
candidates += self.by_key.get(key, [])
|
||||
|
||||
candidates = [event for event in candidates if not event.consumed]
|
||||
|
||||
return min(candidates, key=lambda event: event.order, default=None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def pending_events(events: list):
|
||||
"""Register the provided events as pending for the duration of the context."""
|
||||
registry = _audit_events.get()
|
||||
token = None
|
||||
|
||||
if registry is None:
|
||||
registry = EventRegistry()
|
||||
token = _audit_events.set(registry)
|
||||
|
||||
for event in events:
|
||||
registry.add(event)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for event in events:
|
||||
registry.remove(event)
|
||||
|
||||
if token is not None:
|
||||
_audit_events.reset(token)
|
||||
|
||||
|
||||
def has_pending_event(instance) -> bool:
|
||||
"""Return True if there is a pending (unconsumed) event for the provided instance."""
|
||||
registry = _audit_events.get()
|
||||
return registry is not None and registry.find(instance) is not None
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def flushing_event(event: PendingEvent):
|
||||
"""Consume the provided event for any entry recorded for its instance within this context.
|
||||
|
||||
Used when writing an entry for a specific event (see audit.helpers.write_event_entry),
|
||||
which may not be the first pending event for the instance.
|
||||
"""
|
||||
token = _audit_flush_event.set(event)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_audit_flush_event.reset(token)
|
||||
|
||||
|
||||
def consume_event(instance) -> Optional[PendingEvent]:
|
||||
"""Return (and consume) the first pending event for the provided instance, if any."""
|
||||
event = _audit_flush_event.get()
|
||||
|
||||
if (
|
||||
event is not None
|
||||
and not event.consumed
|
||||
and (
|
||||
event.instance is instance
|
||||
or (
|
||||
(key := instance_key(instance)) is not None
|
||||
and key == instance_key(event.instance)
|
||||
)
|
||||
)
|
||||
):
|
||||
event.consumed = True
|
||||
return event
|
||||
|
||||
registry = _audit_events.get()
|
||||
|
||||
if registry is None:
|
||||
return None
|
||||
|
||||
if event := registry.find(instance):
|
||||
event.consumed = True
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def audit_request(request):
|
||||
"""Track the provided request (and a new correlation ID) for the duration of the context."""
|
||||
@@ -131,6 +284,17 @@ def end_worker_task() -> None:
|
||||
def get_cid() -> Optional[str]:
|
||||
"""Return the current correlation ID (used as AUDITLOG_CID_GETTER).
|
||||
|
||||
Within an audit_batch() context, the ID resolved when the batch was entered is returned.
|
||||
"""
|
||||
if cid := _audit_batch_cid.get():
|
||||
return cid
|
||||
|
||||
return resolve_cid()
|
||||
|
||||
|
||||
def resolve_cid() -> Optional[str]:
|
||||
"""Resolve the current correlation ID.
|
||||
|
||||
- Within a request: the request ID bound by django-structlog, so that audit entries
|
||||
can be matched against log entries (and the X-Request-ID header) for the request.
|
||||
Note: django-structlog uses the incoming 'X-Request-ID' header if provided
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Helper functions for recording audit entries for bulk database operations.
|
||||
"""Helper functions for recording audit entries.
|
||||
|
||||
QuerySet.update() (and bulk_create / bulk_update) bypass model signals,
|
||||
so changes made this way are not recorded by django-auditlog.
|
||||
Code which modifies audited models in bulk should use these helpers instead.
|
||||
- Named events (with notes and deltas) can be recorded against an instance (audit_event / log_event)
|
||||
- QuerySet.update() (and bulk_create / bulk_update) bypass model signals,
|
||||
so changes made this way are not recorded by django-auditlog.
|
||||
Code which modifies audited models in bulk should use the bulk helpers instead.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
@@ -14,6 +18,7 @@ from django.db.models.signals import pre_save
|
||||
|
||||
from auditlog.context import auditlog_disabled
|
||||
from auditlog.registry import auditlog
|
||||
from auditlog.signals import pre_log
|
||||
|
||||
|
||||
def is_audited(model) -> bool:
|
||||
@@ -108,7 +113,13 @@ def audited_update(queryset, **values) -> int:
|
||||
|
||||
with transaction.atomic():
|
||||
if connection.features.has_select_for_update:
|
||||
old_rows = list(queryset.select_for_update())
|
||||
# Only lock rows of the model itself (related rows may be joined via select_related)
|
||||
if connection.features.has_select_for_update_of:
|
||||
locked = queryset.select_for_update(of=('self',))
|
||||
else:
|
||||
locked = queryset.select_for_update()
|
||||
|
||||
old_rows = list(locked)
|
||||
else:
|
||||
old_rows = list(queryset)
|
||||
|
||||
@@ -125,6 +136,9 @@ def audited_update(queryset, **values) -> int:
|
||||
with write_batched():
|
||||
for old in old_rows:
|
||||
if new := new_rows.get(old.pk):
|
||||
# Retain cached related objects (e.g. from select_related)
|
||||
copy_related_cache(old, new)
|
||||
|
||||
_create_log_entry(
|
||||
action=AuditLogEntry.Action.UPDATE,
|
||||
instance=new,
|
||||
@@ -210,9 +224,6 @@ def audited_bulk_create(model, objs: list) -> list:
|
||||
Returns:
|
||||
list: The created objects, as fetched from the database (in insertion order)
|
||||
"""
|
||||
from auditlog.receivers import _create_log_entry
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
from InvenTree.helpers_db import bulk_create_and_fetch
|
||||
|
||||
# Required by bulk_create_and_fetch() - checked for all models (not only audited ones),
|
||||
@@ -241,16 +252,176 @@ def audited_bulk_create(model, objs: list) -> list:
|
||||
obj._state.adding = False
|
||||
obj._state.db = instance._state.db
|
||||
|
||||
if is_audited(model):
|
||||
with write_batched():
|
||||
for instance in created:
|
||||
_create_log_entry(
|
||||
action=AuditLogEntry.Action.CREATE,
|
||||
instance=instance,
|
||||
sender=model,
|
||||
diff_old=None,
|
||||
diff_new=instance,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
record_created(model, created)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def record_created(model, instances) -> None:
|
||||
"""Record a CREATE audit entry for each of the provided (newly created) instances.
|
||||
|
||||
For instances which have been created in bulk (bypassing model signals),
|
||||
e.g. via InvenTree.helpers_db.bulk_create_and_fetch().
|
||||
The entries are written with a single bulk insert.
|
||||
|
||||
Note: Any pending events for the instances (see audit_event) are attached to the entries.
|
||||
"""
|
||||
from auditlog.receivers import _create_log_entry
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
|
||||
if not is_audited(model):
|
||||
return
|
||||
|
||||
with transaction.atomic(), write_batched():
|
||||
for instance in instances:
|
||||
_create_log_entry(
|
||||
action=AuditLogEntry.Action.CREATE,
|
||||
instance=instance,
|
||||
sender=model,
|
||||
diff_old=None,
|
||||
diff_new=instance,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
|
||||
|
||||
def audit_reference(obj) -> dict:
|
||||
"""Return a reference to a model instance, for recording in event deltas.
|
||||
|
||||
The string representation is recorded at the time of the event,
|
||||
so that the reference remains meaningful if the object is later changed or deleted.
|
||||
"""
|
||||
return {
|
||||
'model_type': f'{obj._meta.app_label}.{obj._meta.model_name}',
|
||||
'pk': obj.pk,
|
||||
'str': str(obj),
|
||||
}
|
||||
|
||||
|
||||
def serialize_deltas(value):
|
||||
"""Convert event deltas into a JSON serializable form.
|
||||
|
||||
- Model instances are converted to references (see audit_reference)
|
||||
- Decimal values are converted to float
|
||||
- Dates are converted to ISO format strings
|
||||
"""
|
||||
from django.db import models
|
||||
|
||||
if isinstance(value, models.Model):
|
||||
return audit_reference(value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {str(key): serialize_deltas(val) for key, val in value.items()}
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [serialize_deltas(val) for val in value]
|
||||
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return value.isoformat()
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate_event_type(model, event_type: int) -> int:
|
||||
"""Ensure that the event type is valid for the given model."""
|
||||
from audit.registry import event_type_choices
|
||||
|
||||
if event_type is None:
|
||||
raise ValueError('An event type must be provided')
|
||||
|
||||
if int(event_type) not in event_type_choices(model):
|
||||
raise ValueError(
|
||||
f"Invalid audit event type '{event_type}' for model {model._meta.label}"
|
||||
)
|
||||
|
||||
return int(event_type)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def audit_event(
|
||||
instance, event_type: Optional[int], notes: str = '', deltas=None, actor=None
|
||||
):
|
||||
"""Record a named event against an instance.
|
||||
|
||||
The first audit entry recorded for the instance within this context
|
||||
(e.g. when the instance is saved) carries the event type, notes and deltas.
|
||||
|
||||
If no entry is recorded for the instance (e.g. no fields were changed, or the
|
||||
instance was not saved), an entry is created for the event when the context exits.
|
||||
|
||||
If no event type is provided, the notes / deltas / actor are attached to any entry
|
||||
recorded for the instance within this context - but no entry is created otherwise.
|
||||
|
||||
Contexts can be nested, to record events against multiple instances.
|
||||
|
||||
Arguments:
|
||||
instance: The (audited) model instance
|
||||
event_type: The event type (must be defined in the AUDIT_EVENT_TYPES of the model), or None
|
||||
notes: Optional notes associated with the event
|
||||
deltas: Optional dict of additional information (e.g. references to related objects)
|
||||
Deltas which are also recorded as field changes (of the same entry) are omitted.
|
||||
actor: Optional user who performed the event (defaults to the user of the current request)
|
||||
|
||||
Yields:
|
||||
The pending event (consumed once it has been attached to an audit entry)
|
||||
"""
|
||||
from audit.context import PendingEvent, pending_events
|
||||
from audit.models import NOTES_MAX_LENGTH
|
||||
|
||||
if event_type is not None:
|
||||
event_type = validate_event_type(type(instance), event_type)
|
||||
|
||||
event = PendingEvent(
|
||||
instance,
|
||||
event_type,
|
||||
notes=str(notes)[:NOTES_MAX_LENGTH] if notes else None,
|
||||
deltas=serialize_deltas(deltas) if deltas else None,
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
with pending_events([event]):
|
||||
yield event
|
||||
|
||||
# An entry is only created without any changes for a *named* event
|
||||
if not event.consumed and event.event_type is not None:
|
||||
write_event_entry(event)
|
||||
|
||||
|
||||
def write_event_entry(event) -> None:
|
||||
"""Write an audit entry for an event which has not been attached to any other entry."""
|
||||
from audit.context import flushing_event
|
||||
from audit.models import AuditLogEntry
|
||||
|
||||
instance = event.instance
|
||||
|
||||
# The instance was never saved (or has since been deleted)
|
||||
if instance.pk is None or not is_audited(type(instance)):
|
||||
return
|
||||
|
||||
action = AuditLogEntry.Action.UPDATE
|
||||
|
||||
# Apply the same checks as for any other entry (e.g. audit log disabled)
|
||||
results = pre_log.send(type(instance), instance=instance, action=action)
|
||||
|
||||
if any(result is False for _, result in results):
|
||||
return
|
||||
|
||||
# Attach this event to the entry - not the first pending event for the instance,
|
||||
# which may belong to an (outer) context which has not yet exited
|
||||
with flushing_event(event):
|
||||
AuditLogEntry.objects.log_create(
|
||||
instance, action=action, changes={}, force_log=True
|
||||
)
|
||||
|
||||
|
||||
def log_event(
|
||||
instance, event_type: int, notes: str = '', deltas=None, actor=None
|
||||
) -> None:
|
||||
"""Record a named event against an instance, which does not involve saving the instance."""
|
||||
validate_event_type(type(instance), event_type)
|
||||
|
||||
with audit_event(instance, event_type, notes=notes, deltas=deltas, actor=actor):
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-25 02:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("audit", "0002_actor_username"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="auditlogentry",
|
||||
name="event_type",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name="Event Type"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditlogentry",
|
||||
name="notes",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Notes associated with this entry",
|
||||
max_length=512,
|
||||
null=True,
|
||||
verbose_name="Notes",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Database models for the audit app."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
@@ -14,6 +15,9 @@ import audit.context
|
||||
|
||||
MASKED_VALUE = '***'
|
||||
|
||||
# Maximum length of the notes associated with an audit entry
|
||||
NOTES_MAX_LENGTH = 512
|
||||
|
||||
|
||||
def get_audit_parent_reference(instance):
|
||||
"""Return a (ContentType, pk) reference to the audit "parent" of an instance (or None)."""
|
||||
@@ -161,7 +165,13 @@ class AuditLogEntryManager(LogEntryManager):
|
||||
kwargs.setdefault('parent_content_type', parent_ct)
|
||||
kwargs.setdefault('parent_object_pk', parent_pk)
|
||||
|
||||
if 'actor' not in kwargs and (actor := audit.context.get_request_actor()):
|
||||
# A pending named event for this instance (see audit.helpers.audit_event)
|
||||
event = audit.context.consume_event(instance)
|
||||
|
||||
# The actor is (in order of preference) provided explicitly, by the event, or by the request
|
||||
if 'actor' not in kwargs and (
|
||||
actor := (event and event.actor) or audit.context.get_request_actor()
|
||||
):
|
||||
kwargs['actor'] = actor
|
||||
kwargs['actor_email'] = getattr(actor, 'email', None)
|
||||
|
||||
@@ -179,12 +189,35 @@ class AuditLogEntryManager(LogEntryManager):
|
||||
|
||||
kwargs['additional_data'] = {**(additional_data or {}), 'source': source}
|
||||
|
||||
# Attach the pending named event
|
||||
if event:
|
||||
kwargs['event_type'] = event.event_type
|
||||
kwargs['notes'] = event.notes or None
|
||||
|
||||
# Deltas which are already recorded as field changes are not duplicated
|
||||
changes = kwargs.get('changes')
|
||||
deltas = {
|
||||
key: value
|
||||
for key, value in (event.deltas or {}).items()
|
||||
if not (isinstance(changes, dict) and key in changes)
|
||||
}
|
||||
|
||||
if deltas:
|
||||
kwargs['additional_data'] = {
|
||||
**(kwargs.get('additional_data') or {}),
|
||||
'deltas': deltas,
|
||||
}
|
||||
|
||||
is_masked = getattr(instance, 'is_audit_masked', None)
|
||||
|
||||
if callable(is_masked) and is_masked():
|
||||
# Mask at write time - sensitive values must never reach the log table
|
||||
kwargs['changes'] = mask_changes(kwargs.get('changes'))
|
||||
kwargs['serialized_data'] = None
|
||||
kwargs['notes'] = None
|
||||
|
||||
if additional_data := kwargs.get('additional_data'):
|
||||
additional_data.pop('deltas', None)
|
||||
|
||||
return kwargs
|
||||
|
||||
@@ -324,6 +357,29 @@ class AuditLogEntry(AbstractLogEntry):
|
||||
max_length=150, null=True, blank=True, verbose_name=_('Actor Username')
|
||||
)
|
||||
|
||||
# Named event type (see InvenTreeAuditMixin.AUDIT_EVENT_TYPES)
|
||||
event_type = models.PositiveIntegerField(
|
||||
null=True, blank=True, verbose_name=_('Event Type')
|
||||
)
|
||||
|
||||
notes = models.CharField(
|
||||
max_length=NOTES_MAX_LENGTH,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Notes'),
|
||||
help_text=_('Notes associated with this entry'),
|
||||
)
|
||||
|
||||
@property
|
||||
def event_label(self) -> Optional[str]:
|
||||
"""Return the label for the event type of this entry (if any)."""
|
||||
from audit.registry import event_type_label
|
||||
|
||||
if self.event_type is None:
|
||||
return None
|
||||
|
||||
return event_type_label(self.content_type.model_class(), self.event_type)
|
||||
|
||||
@property
|
||||
def model_type(self) -> str:
|
||||
"""Return the 'app_label.model' string for the audited model."""
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Registration of audited models with django-auditlog."""
|
||||
|
||||
import functools
|
||||
from typing import Optional
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
import structlog
|
||||
@@ -103,3 +106,42 @@ def get_view_restriction(model):
|
||||
return restriction
|
||||
|
||||
return AUDIT_VIEW_RESTRICTIONS.get(model._meta.label_lower)
|
||||
|
||||
|
||||
def event_type_choices(model) -> dict[int, str]:
|
||||
"""Return a mapping of event type value -> label for the given model.
|
||||
|
||||
Event types are defined by the AUDIT_EVENT_TYPES attribute of the model,
|
||||
which may be a StatusCode enumeration or a Django Choices class.
|
||||
"""
|
||||
enum = getattr(model, 'AUDIT_EVENT_TYPES', None)
|
||||
|
||||
if enum is None:
|
||||
return {}
|
||||
|
||||
return _enum_choices(enum)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _enum_choices(enum) -> dict[int, str]:
|
||||
"""Return a mapping of value -> label for an event type enumeration (cached).
|
||||
|
||||
Note: Labels are returned as (lazy) translatable strings,
|
||||
so that they are translated at the point of use.
|
||||
"""
|
||||
# StatusCode enumeration (e.g. StockHistoryCode)
|
||||
if callable(getattr(enum, 'items', None)):
|
||||
return dict(enum.items(custom=False))
|
||||
|
||||
# Django Choices class
|
||||
return {int(key): label for key, label in enum.choices}
|
||||
|
||||
|
||||
def event_type_label(model, event_type: int) -> Optional[str]:
|
||||
"""Return the label for an event type of the given model (or None if unknown)."""
|
||||
if model is None or event_type is None:
|
||||
return None
|
||||
|
||||
label = event_type_choices(model).get(int(event_type))
|
||||
|
||||
return None if label is None else str(label)
|
||||
@@ -81,6 +81,10 @@ class AuditLogEntrySerializer(
|
||||
'actor',
|
||||
'actor_detail',
|
||||
'actor_username',
|
||||
'event_type',
|
||||
'event_label',
|
||||
'notes',
|
||||
'deltas',
|
||||
'model_type',
|
||||
'model_label',
|
||||
'object_pk',
|
||||
@@ -115,6 +119,42 @@ class AuditLogEntrySerializer(
|
||||
read_only=True, allow_null=True, label=_('Parent Model Type')
|
||||
)
|
||||
|
||||
event_label = serializers.CharField(
|
||||
read_only=True, allow_null=True, label=_('Event')
|
||||
)
|
||||
|
||||
deltas = serializers.SerializerMethodField(label=_('Deltas'))
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_deltas(self, entry: AuditLogEntry) -> Optional[dict]:
|
||||
"""Return the deltas recorded against a named event.
|
||||
|
||||
References to objects which the user cannot view are redacted.
|
||||
"""
|
||||
deltas = (entry.additional_data or {}).get('deltas')
|
||||
|
||||
if not isinstance(deltas, dict):
|
||||
return None
|
||||
|
||||
return {key: self.redact_reference(value) for key, value in deltas.items()}
|
||||
|
||||
def redact_reference(self, value):
|
||||
"""Redact a reference to a model instance, if the user cannot view the model."""
|
||||
from django.apps import apps
|
||||
|
||||
if not (isinstance(value, dict) and 'model_type' in value and 'pk' in value):
|
||||
return value
|
||||
|
||||
try:
|
||||
model = apps.get_model(value['model_type'])
|
||||
except (LookupError, ValueError):
|
||||
model = None
|
||||
|
||||
if self.can_view_model(model):
|
||||
return value
|
||||
|
||||
return {**value, 'pk': None, 'str': None}
|
||||
|
||||
changes = serializers.SerializerMethodField(label=_('Changes'))
|
||||
|
||||
changes_detail = OptionalField(
|
||||
@@ -297,6 +337,15 @@ class AuditLogEntrySerializer(
|
||||
for field in self.STAFF_ONLY_FIELDS:
|
||||
data.pop(field, None)
|
||||
|
||||
# Event deltas are provided (with redaction) via the 'deltas' field
|
||||
if isinstance(data.get('additional_data'), dict):
|
||||
additional_data = {
|
||||
key: value
|
||||
for key, value in data['additional_data'].items()
|
||||
if key != 'deltas'
|
||||
}
|
||||
data['additional_data'] = additional_data or None
|
||||
|
||||
# Only expose the parent reference if the user may view the parent model
|
||||
if parent_ct := instance.parent_content_type:
|
||||
if not self.can_view_model(parent_ct.model_class()):
|
||||
|
||||
@@ -7,10 +7,12 @@ PurchaseOrder (parent) and PurchaseOrderLineItem (child) with the audit log.
|
||||
import contextlib
|
||||
from base64 import b64encode
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models, transaction
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -18,6 +20,7 @@ from django.utils import timezone
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
import audit.context
|
||||
from audit.helpers import write_batched
|
||||
from audit.models import MASKED_VALUE, AuditLogEntry, mask_changes
|
||||
from audit.registry import BASE_EXCLUDE_FIELDS, audited_models, register_model
|
||||
from audit.serializers import AuditLogEntrySerializer
|
||||
@@ -145,6 +148,7 @@ class AuditRegistryTest(TestCase):
|
||||
'pricing.partinternalpricebreak',
|
||||
'pricing.partpricing',
|
||||
'pricing.partsellpricebreak',
|
||||
'stock.stockitem',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -269,6 +273,32 @@ class AuditModelTest(AuditTestMixin, TestCase):
|
||||
self.assertIsNone(audit.context.get_source())
|
||||
self.assertIsNone(audit.context.get_cid())
|
||||
|
||||
def test_batch_cid(self):
|
||||
"""The correlation ID is resolved once for an audit batch."""
|
||||
with audit.context.audit_source('task:test_task'):
|
||||
cid = audit.context.get_cid()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
audit.context, 'resolve_cid', wraps=audit.context.resolve_cid
|
||||
) as resolve,
|
||||
transaction.atomic(),
|
||||
write_batched(),
|
||||
):
|
||||
for idx in range(3):
|
||||
self.po.description = f'Batched {idx}'
|
||||
self.po.save()
|
||||
|
||||
self.assertEqual(resolve.call_count, 1)
|
||||
|
||||
entries = self.entries_for(self.po, action=AuditLogEntry.Action.UPDATE)
|
||||
|
||||
self.assertEqual(entries.count(), 3)
|
||||
self.assertEqual({entry.cid for entry in entries}, {cid})
|
||||
|
||||
# Context is reset afterwards
|
||||
self.assertIsNone(audit.context.get_cid())
|
||||
|
||||
def test_disabled(self):
|
||||
"""No entries are recorded when the audit log is disabled."""
|
||||
set_global_setting('AUDIT_LOG_ENABLED', False)
|
||||
@@ -536,8 +566,8 @@ class AuditAPITest(AuditTestMixin, InvenTreeAPITestCase):
|
||||
len(line_entries({'model_type': 'purchaseorderlineitem'})), 1
|
||||
)
|
||||
|
||||
def test_staff_required(self):
|
||||
"""The audit log API is only available to staff users."""
|
||||
def test_non_staff(self):
|
||||
"""Non-staff users can view entries for models they have permission to view."""
|
||||
self.patch_po('Edit')
|
||||
|
||||
entry = self.entries_for(self.po).first()
|
||||
@@ -548,12 +578,24 @@ class AuditAPITest(AuditTestMixin, InvenTreeAPITestCase):
|
||||
self.assertEqual(data[0]['remote_addr'], '127.0.0.1')
|
||||
self.get(detail_url)
|
||||
|
||||
# Non-staff users are denied - even with view permission on the audited model
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
self.get(self.list_url(), expected_code=403)
|
||||
self.get(detail_url, expected_code=403)
|
||||
# Non-staff users can view the entries - but not the remote address
|
||||
data = self.po_history()
|
||||
self.assertGreater(len(data), 0)
|
||||
self.assertNotIn('remote_addr', data[0])
|
||||
self.assertNotIn('remote_addr', self.get(detail_url).data)
|
||||
|
||||
# ...but not without view permission on the audited model
|
||||
self.clearRoles()
|
||||
|
||||
self.assertEqual(self.po_history(), [])
|
||||
self.get(detail_url, expected_code=404)
|
||||
|
||||
# Anonymous users are denied
|
||||
self.client.logout()
|
||||
self.get(self.list_url(), expected_code=401)
|
||||
|
||||
def test_view_restriction(self):
|
||||
"""Models with a view restriction are hidden from users without that status."""
|
||||
@@ -1259,3 +1301,305 @@ class AuditRetentionTest(InvenTreeAPITestCase):
|
||||
data = self.get(url).data
|
||||
self.assertIsNone(data['actor'])
|
||||
self.assertEqual(data['actor_username'], 'leaver')
|
||||
|
||||
|
||||
class AuditTestEvents(models.IntegerChoices):
|
||||
"""Event types used for testing named audit events."""
|
||||
|
||||
MOVED = 10, 'Moved'
|
||||
COUNTED = 20, 'Counted'
|
||||
|
||||
|
||||
class AuditEventTest(InvenTreeAPITestCase):
|
||||
"""Tests for named audit events (see audit.helpers.audit_event)."""
|
||||
|
||||
roles = ['purchase_order.view', 'purchase_order.change']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Define event types for the Company model."""
|
||||
cls.events_patch = mock.patch.object(
|
||||
Company, 'AUDIT_EVENT_TYPES', AuditTestEvents
|
||||
)
|
||||
cls.events_patch.start()
|
||||
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Remove event types from the Company model."""
|
||||
super().tearDownClass()
|
||||
cls.events_patch.stop()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Create test data."""
|
||||
super().setUpTestData()
|
||||
|
||||
cls.company = Company.objects.create(name='Event Company', is_supplier=True)
|
||||
cls.part = Part.objects.create(name='Event Part', description='A part')
|
||||
|
||||
def entries(self):
|
||||
"""Return all entries for the test company (oldest first)."""
|
||||
return AuditLogEntry.objects.get_for_object(self.company).order_by('pk')
|
||||
|
||||
def test_event_with_changes(self):
|
||||
"""The event is attached to the entry recorded when the instance is saved."""
|
||||
from audit.helpers import audit_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
with audit_event(
|
||||
self.company,
|
||||
AuditTestEvents.MOVED,
|
||||
notes='Moved company',
|
||||
deltas={'part': self.part, 'quantity': Decimal('1.5')},
|
||||
):
|
||||
self.company.description = 'Moved'
|
||||
self.company.save()
|
||||
|
||||
# A single entry, which records both the changes and the event
|
||||
self.assertEqual(self.entries().count(), n + 1)
|
||||
|
||||
entry = self.entries().last()
|
||||
|
||||
self.assertEqual(entry.event_type, AuditTestEvents.MOVED)
|
||||
self.assertEqual(entry.event_label, 'Moved')
|
||||
self.assertEqual(entry.notes, 'Moved company')
|
||||
self.assertEqual(entry.changes_dict['description'], ['', 'Moved'])
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas'],
|
||||
{
|
||||
'part': {
|
||||
'model_type': 'part.part',
|
||||
'pk': self.part.pk,
|
||||
'str': str(self.part),
|
||||
},
|
||||
'quantity': 1.5,
|
||||
},
|
||||
)
|
||||
|
||||
def test_event_without_changes(self):
|
||||
"""An event is recorded even if no fields are changed."""
|
||||
from audit.helpers import audit_event, log_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
# Saved, but not changed
|
||||
with audit_event(self.company, AuditTestEvents.COUNTED, notes='Counted'):
|
||||
self.company.save()
|
||||
|
||||
# Not saved at all
|
||||
log_event(self.company, AuditTestEvents.COUNTED, deltas={'quantity': 5})
|
||||
|
||||
entries = list(self.entries()[n:])
|
||||
|
||||
self.assertEqual(len(entries), 2)
|
||||
|
||||
for entry in entries:
|
||||
self.assertEqual(entry.action, AuditLogEntry.Action.UPDATE)
|
||||
self.assertEqual(entry.event_type, AuditTestEvents.COUNTED)
|
||||
self.assertEqual(entry.changes_dict, {})
|
||||
|
||||
self.assertEqual(entries[0].notes, 'Counted')
|
||||
self.assertEqual(entries[1].additional_data['deltas'], {'quantity': 5})
|
||||
|
||||
def test_unnamed_event(self):
|
||||
"""Notes (without an event type) are attached to an entry - but do not create one."""
|
||||
from audit.helpers import audit_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
# No changes - no entry
|
||||
with audit_event(self.company, None, notes='Nothing changed'):
|
||||
self.company.save()
|
||||
|
||||
self.assertEqual(self.entries().count(), n)
|
||||
|
||||
with audit_event(self.company, None, notes='Changed'):
|
||||
self.company.description = 'Unnamed'
|
||||
self.company.save()
|
||||
|
||||
[entry] = list(self.entries()[n:])
|
||||
|
||||
self.assertIsNone(entry.event_type)
|
||||
self.assertIsNone(entry.event_label)
|
||||
self.assertEqual(entry.notes, 'Changed')
|
||||
|
||||
def test_no_changes_without_event(self):
|
||||
"""Saving an instance without changes (and without an event) is not recorded."""
|
||||
n = self.entries().count()
|
||||
self.company.save()
|
||||
self.assertEqual(self.entries().count(), n)
|
||||
|
||||
def test_multiple_instances(self):
|
||||
"""Events can be recorded against multiple instances (one entry each)."""
|
||||
from audit.helpers import audit_event
|
||||
|
||||
other = Company.objects.create(name='Other Company')
|
||||
|
||||
with (
|
||||
audit_event(self.company, AuditTestEvents.MOVED, deltas={'to': other}),
|
||||
audit_event(other, AuditTestEvents.COUNTED, deltas={'from': self.company}),
|
||||
):
|
||||
self.company.description = 'A'
|
||||
self.company.save()
|
||||
|
||||
entry = self.entries().last()
|
||||
self.assertEqual(entry.event_type, AuditTestEvents.MOVED)
|
||||
self.assertEqual(entry.additional_data['deltas']['to']['pk'], other.pk)
|
||||
|
||||
# The other instance was not saved - so an entry is created for the event
|
||||
other_entry = AuditLogEntry.objects.get_for_object(other).order_by('pk').last()
|
||||
self.assertEqual(other_entry.event_type, AuditTestEvents.COUNTED)
|
||||
self.assertEqual(other_entry.changes_dict, {})
|
||||
|
||||
def test_nested_events_same_instance(self):
|
||||
"""Nested events for the same (unsaved) instance each create an entry."""
|
||||
from contextlib import ExitStack
|
||||
|
||||
from audit.helpers import audit_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
# Contexts exit in reverse order (newest event first)
|
||||
with ExitStack() as stack:
|
||||
for quantity in [1, 2]:
|
||||
stack.enter_context(
|
||||
audit_event(
|
||||
self.company,
|
||||
AuditTestEvents.COUNTED,
|
||||
deltas={'quantity': quantity},
|
||||
)
|
||||
)
|
||||
|
||||
entries = list(self.entries()[n:])
|
||||
|
||||
self.assertEqual(len(entries), 2)
|
||||
self.assertEqual(
|
||||
sorted(entry.additional_data['deltas']['quantity'] for entry in entries),
|
||||
[1, 2],
|
||||
)
|
||||
|
||||
def test_new_instance(self):
|
||||
"""An event can be attached to the creation of a new instance."""
|
||||
from audit.helpers import audit_event
|
||||
|
||||
company = Company(name='New Company')
|
||||
|
||||
with audit_event(company, AuditTestEvents.MOVED, notes='Created'):
|
||||
company.save()
|
||||
|
||||
[entry] = AuditLogEntry.objects.get_for_object(company)
|
||||
|
||||
self.assertEqual(entry.action, AuditLogEntry.Action.CREATE)
|
||||
self.assertEqual(entry.event_type, AuditTestEvents.MOVED)
|
||||
self.assertEqual(entry.notes, 'Created')
|
||||
|
||||
def test_invalid_event_type(self):
|
||||
"""Event types must be defined by the model."""
|
||||
from audit.helpers import log_event
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
log_event(self.company, 999)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
log_event(self.company, None)
|
||||
|
||||
# Models without event types
|
||||
with self.assertRaises(ValueError):
|
||||
log_event(self.part, AuditTestEvents.MOVED)
|
||||
|
||||
def test_error_not_recorded(self):
|
||||
"""No event entry is created if an error occurs within the context."""
|
||||
from audit.helpers import audit_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
with (
|
||||
self.assertRaises(RuntimeError),
|
||||
audit_event(self.company, AuditTestEvents.MOVED),
|
||||
):
|
||||
raise RuntimeError('Failed')
|
||||
|
||||
self.assertEqual(self.entries().count(), n)
|
||||
|
||||
def test_disabled(self):
|
||||
"""Events are not recorded when auditing is disabled."""
|
||||
from auditlog.context import disable_auditlog
|
||||
|
||||
from audit.helpers import log_event
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
with disable_auditlog():
|
||||
log_event(self.company, AuditTestEvents.MOVED)
|
||||
|
||||
set_global_setting('AUDIT_LOG_ENABLED', False)
|
||||
self.addCleanup(set_global_setting, 'AUDIT_LOG_ENABLED', True)
|
||||
|
||||
log_event(self.company, AuditTestEvents.MOVED)
|
||||
|
||||
self.assertEqual(self.entries().count(), n)
|
||||
|
||||
def test_batched(self):
|
||||
"""Event entries can be written in a batch."""
|
||||
from django.db import transaction
|
||||
|
||||
from audit.helpers import log_event, write_batched
|
||||
|
||||
n = self.entries().count()
|
||||
|
||||
with transaction.atomic(), write_batched():
|
||||
log_event(self.company, AuditTestEvents.MOVED)
|
||||
log_event(self.company, AuditTestEvents.COUNTED)
|
||||
|
||||
# Not written until the batch is complete
|
||||
self.assertEqual(self.entries().count(), n)
|
||||
|
||||
self.assertEqual(
|
||||
[entry.event_type for entry in self.entries()[n:]],
|
||||
[AuditTestEvents.MOVED, AuditTestEvents.COUNTED],
|
||||
)
|
||||
|
||||
def test_api(self):
|
||||
"""Event information is provided via the API (with redacted references)."""
|
||||
from audit.helpers import log_event
|
||||
|
||||
log_event(
|
||||
self.company,
|
||||
AuditTestEvents.MOVED,
|
||||
notes='Via API',
|
||||
deltas={'part': self.part, 'quantity': 3},
|
||||
)
|
||||
|
||||
entry = self.entries().last()
|
||||
url = reverse('api-audit-log-list')
|
||||
|
||||
# Filter by event type
|
||||
data = self.get(url, {'event_type': AuditTestEvents.MOVED}).data
|
||||
self.assertEqual([item['pk'] for item in data], [entry.pk])
|
||||
self.assertEqual(
|
||||
self.get(url, {'event_type': AuditTestEvents.COUNTED}).data, []
|
||||
)
|
||||
|
||||
[item] = data
|
||||
|
||||
self.assertEqual(item['event_type'], AuditTestEvents.MOVED)
|
||||
self.assertEqual(item['event_label'], 'Moved')
|
||||
self.assertEqual(item['notes'], 'Via API')
|
||||
self.assertEqual(item['deltas']['quantity'], 3)
|
||||
|
||||
# Deltas are only provided via the 'deltas' field
|
||||
self.assertIsNone(item['additional_data'])
|
||||
|
||||
# The user cannot view parts - the reference is redacted
|
||||
self.assertEqual(
|
||||
item['deltas']['part'], {'model_type': 'part.part', 'pk': None, 'str': None}
|
||||
)
|
||||
|
||||
self.assignRole('part.view')
|
||||
|
||||
item = self.get(url, {'event_type': AuditTestEvents.MOVED}).data[0]
|
||||
self.assertEqual(item['deltas']['part']['pk'], self.part.pk)
|
||||
self.assertEqual(item['deltas']['part']['str'], str(self.part))
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import copy
|
||||
import decimal
|
||||
from contextlib import ExitStack
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@@ -31,6 +32,13 @@ import part.models
|
||||
import report.mixins
|
||||
import stock.models
|
||||
import users.models
|
||||
from audit.helpers import (
|
||||
audit_event,
|
||||
audited_bulk_update,
|
||||
copy_related_cache,
|
||||
record_created,
|
||||
write_batched,
|
||||
)
|
||||
from build.events import BuildEvents
|
||||
from build.filters import annotate_allocated_quantity, annotate_required_quantity
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
@@ -687,7 +695,7 @@ class Build(
|
||||
|
||||
Notes:
|
||||
This unrolls what would otherwise be a per-BuildItem call, so that the underlying StockItem,
|
||||
StockItemTracking, BuildLine and BuildItem writes can be batched into
|
||||
audit log, BuildLine and BuildItem writes can be batched into
|
||||
a handful of bulk queries instead of several per build item.
|
||||
"""
|
||||
import part.tasks
|
||||
@@ -704,7 +712,15 @@ class Build(
|
||||
# Preselect related fields to avoid per-row database queries below
|
||||
build_items = list(
|
||||
build_items.select_related(
|
||||
'build_line', 'stock_item', 'stock_item__part', 'install_into'
|
||||
'build_line',
|
||||
'stock_item',
|
||||
'stock_item__part',
|
||||
'stock_item__location',
|
||||
'stock_item__purchase_order__supplier',
|
||||
'install_into',
|
||||
'install_into__part',
|
||||
'install_into__location',
|
||||
'install_into__purchase_order__supplier',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -843,8 +859,10 @@ class Build(
|
||||
# which the tracking entries and (for installed items) BuildItem records need below
|
||||
new_stock_item_data = [new_item for _, new_item, _ in split_items]
|
||||
|
||||
new_stock_items = bulk_create_and_fetch(
|
||||
stock.models.StockItem, new_stock_item_data
|
||||
# Evaluate the queryset immediately (into a list) - otherwise, indexing into it
|
||||
# below would re-query the database once per split item
|
||||
new_stock_items = list(
|
||||
bulk_create_and_fetch(stock.models.StockItem, new_stock_item_data)
|
||||
)
|
||||
|
||||
# Back-populate the resolved primary keys onto the original 'new_item' objects
|
||||
@@ -853,68 +871,23 @@ class Build(
|
||||
# resolved pk too. On backends that can't return pks from bulk_create (MySQL),
|
||||
# 'new_stock_items' are freshly-fetched, distinct objects, so a plain list
|
||||
# replacement here would leave those other references with pk=None.
|
||||
for i, (_stock_item, new_item, _quantity) in enumerate(split_items):
|
||||
for i, (source_item, new_item, _quantity) in enumerate(split_items):
|
||||
new_item.pk = new_stock_items[i].pk
|
||||
new_item._state.adding = False
|
||||
|
||||
# Retain the cached related objects (avoid per-item lookups)
|
||||
copy_related_cache(source_item, new_item)
|
||||
copy_related_cache(source_item, new_stock_items[i])
|
||||
|
||||
tracking_entries = []
|
||||
split_events = []
|
||||
install_events = []
|
||||
|
||||
# Split stock items for "split_items"
|
||||
for source_item, new_item, quantity in split_items:
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=new_item.pk,
|
||||
part_id=new_item.part_id,
|
||||
tracking_type=StockHistoryCode.SPLIT_FROM_PARENT.value,
|
||||
user=user,
|
||||
notes=notes,
|
||||
deltas={'stockitem': source_item.pk, 'quantity': float(quantity)},
|
||||
)
|
||||
)
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=source_item.pk,
|
||||
part_id=source_item.part_id,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
user=user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'removed': float(quantity),
|
||||
'quantity': float(source_item.quantity),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for source_item, new_item, _quantity in split_items:
|
||||
split_events.append(((), {'id': new_item.pk, 'parent': source_item.pk}))
|
||||
|
||||
# Install stock items for "install_items"
|
||||
for build_item, target_item, output, quantity in install_items:
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=target_item.pk,
|
||||
part_id=target_item.part_id,
|
||||
tracking_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY.value,
|
||||
user=user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'stockitem': output.pk,
|
||||
'quantity': float(quantity),
|
||||
'buildorder': self.pk,
|
||||
},
|
||||
)
|
||||
)
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=output.pk,
|
||||
part_id=output.part_id,
|
||||
tracking_type=StockHistoryCode.INSTALLED_CHILD_ITEM.value,
|
||||
user=user,
|
||||
notes=notes,
|
||||
deltas={'stockitem': target_item.pk, 'quantity': float(quantity)},
|
||||
)
|
||||
)
|
||||
|
||||
for build_item, target_item, output, _quantity in install_items:
|
||||
install_events.append((
|
||||
(),
|
||||
{'id': target_item.pk, 'assembly_id': output.pk},
|
||||
@@ -923,27 +896,55 @@ class Build(
|
||||
# Ensure the build item points to the (possibly newly split) stock item
|
||||
build_item.stock_item = target_item
|
||||
|
||||
# Consume stock items for "consume_items"
|
||||
for target_item, quantity in consume_items:
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=target_item.pk,
|
||||
part_id=target_item.part_id,
|
||||
tracking_type=StockHistoryCode.BUILD_CONSUMED.value,
|
||||
user=user,
|
||||
notes=notes,
|
||||
deltas={'buildorder': self.pk, 'quantity': float(quantity)},
|
||||
# Record the operation in the audit log - a single entry for each stock item
|
||||
with write_batched(), ExitStack() as events:
|
||||
|
||||
def audit(item, code, deltas):
|
||||
"""Record a named event against the provided stock item."""
|
||||
events.enter_context(
|
||||
audit_event(item, code, notes=notes, deltas=deltas, actor=user)
|
||||
)
|
||||
|
||||
for source_item, new_item, quantity in split_items:
|
||||
audit(
|
||||
source_item,
|
||||
StockHistoryCode.SPLIT_CHILD_ITEM,
|
||||
{
|
||||
'stockitem': new_item,
|
||||
'removed': quantity,
|
||||
'quantity': source_item.quantity,
|
||||
},
|
||||
)
|
||||
|
||||
for _build_item, target_item, output, quantity in install_items:
|
||||
audit(
|
||||
target_item,
|
||||
StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||
{'stockitem': output, 'quantity': quantity, 'buildorder': self},
|
||||
)
|
||||
audit(
|
||||
output,
|
||||
StockHistoryCode.INSTALLED_CHILD_ITEM,
|
||||
{'stockitem': target_item, 'quantity': quantity},
|
||||
)
|
||||
|
||||
for target_item, quantity in consume_items:
|
||||
audit(
|
||||
target_item,
|
||||
StockHistoryCode.BUILD_CONSUMED,
|
||||
{'buildorder': self, 'quantity': quantity},
|
||||
)
|
||||
|
||||
# Newly split items are recorded as created (with the consume / install event)
|
||||
record_created(stock.models.StockItem, new_stock_items)
|
||||
|
||||
# Flush all StockItem field changes (quantity reductions, consumption, installs)
|
||||
audited_bulk_update(
|
||||
stock.models.StockItem,
|
||||
list(seen_stock_items.values()),
|
||||
['quantity', 'consumed_by', 'location', 'belongs_to'],
|
||||
)
|
||||
|
||||
# Flush all StockItem field changes (quantity reductions, consumption, installs)
|
||||
stock.models.StockItem.objects.bulk_update(
|
||||
seen_stock_items.values(),
|
||||
['quantity', 'consumed_by', 'location', 'belongs_to'],
|
||||
)
|
||||
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking_entries)
|
||||
|
||||
# Update build lines for "seen_build_lines"
|
||||
BuildLine.objects.bulk_update(seen_build_lines.values(), ['consumed'])
|
||||
|
||||
@@ -1181,12 +1182,22 @@ class Build(
|
||||
if serials:
|
||||
"""Create multiple build outputs with a single quantity of 1."""
|
||||
|
||||
# Create tracking entries for each item
|
||||
tracking = []
|
||||
allocations = []
|
||||
|
||||
# Record a tracking event for each new output
|
||||
outputs = stock.models.StockItem._create_serial_numbers(
|
||||
serials,
|
||||
tracking=lambda output: {
|
||||
'entry_type': StockHistoryCode.BUILD_OUTPUT_CREATED,
|
||||
'user': user,
|
||||
'deltas': {
|
||||
'quantity': 1,
|
||||
'buildorder': self,
|
||||
'batch': output.batch,
|
||||
'serial': output.serial,
|
||||
'location': location,
|
||||
},
|
||||
},
|
||||
part=self.part,
|
||||
build=self,
|
||||
batch=batch,
|
||||
@@ -1195,21 +1206,6 @@ class Build(
|
||||
)
|
||||
|
||||
for output in outputs:
|
||||
# Generate a new historical tracking entry
|
||||
if entry := output.add_tracking_entry(
|
||||
StockHistoryCode.BUILD_OUTPUT_CREATED,
|
||||
user,
|
||||
deltas={
|
||||
'quantity': 1,
|
||||
'buildorder': self.pk,
|
||||
'batch': output.batch,
|
||||
'serial': output.serial,
|
||||
'location': location.pk if location else None,
|
||||
},
|
||||
commit=False,
|
||||
):
|
||||
tracking.append(entry)
|
||||
|
||||
# Auto-allocate stock based on serial number
|
||||
if auto_allocate:
|
||||
if new_allocations := self.auto_allocate_tracked_output(
|
||||
@@ -1217,16 +1213,13 @@ class Build(
|
||||
):
|
||||
allocations.extend(new_allocations)
|
||||
|
||||
# Bulk create tracking entries
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking, batch_size=250)
|
||||
|
||||
# Generate stock allocations
|
||||
BuildItem.objects.bulk_create(allocations, batch_size=250)
|
||||
|
||||
else:
|
||||
"""Create a single build output of the given quantity."""
|
||||
|
||||
output = stock.models.StockItem.objects.create(
|
||||
output = stock.models.StockItem(
|
||||
quantity=quantity,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@@ -1235,16 +1228,17 @@ class Build(
|
||||
is_building=True,
|
||||
)
|
||||
|
||||
output.add_tracking_entry(
|
||||
with output.tracking_event(
|
||||
StockHistoryCode.BUILD_OUTPUT_CREATED,
|
||||
user,
|
||||
deltas={
|
||||
'quantity': float(quantity),
|
||||
'buildorder': self.pk,
|
||||
'buildorder': self,
|
||||
'batch': batch,
|
||||
'location': location.pk if location else None,
|
||||
'location': location,
|
||||
},
|
||||
)
|
||||
):
|
||||
output.save()
|
||||
|
||||
# Ensure we return a QuerySet object here, too
|
||||
outputs = stock.models.StockItem.objects.filter(pk=output.pk)
|
||||
@@ -1384,32 +1378,31 @@ class Build(
|
||||
)
|
||||
output.build = self
|
||||
|
||||
# Update build output item
|
||||
output.is_building = False
|
||||
output.status = StockStatus.REJECTED.value
|
||||
output.location = location
|
||||
output.save(add_note=False)
|
||||
|
||||
allocated_items = output.items_to_install.all()
|
||||
|
||||
# Complete or discard allocations
|
||||
if not discard_allocations:
|
||||
self.complete_allocations(build_items=allocated_items, user=user)
|
||||
|
||||
# Delete allocations
|
||||
allocated_items.all().delete()
|
||||
|
||||
output.add_tracking_entry(
|
||||
with output.tracking_event(
|
||||
StockHistoryCode.BUILD_OUTPUT_REJECTED,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'quantity': float(quantity),
|
||||
'location': location.pk,
|
||||
'location': location,
|
||||
'status': StockStatus.REJECTED.value,
|
||||
'buildorder': self.pk,
|
||||
'buildorder': self,
|
||||
},
|
||||
)
|
||||
):
|
||||
# Update build output item
|
||||
output.is_building = False
|
||||
output.status = StockStatus.REJECTED.value
|
||||
output.location = location
|
||||
output.save(add_note=False)
|
||||
|
||||
allocated_items = output.items_to_install.all()
|
||||
|
||||
# Complete or discard allocations
|
||||
if not discard_allocations:
|
||||
self.complete_allocations(build_items=allocated_items, user=user)
|
||||
|
||||
# Delete allocations
|
||||
allocated_items.all().delete()
|
||||
|
||||
def can_complete_output(
|
||||
self,
|
||||
@@ -1527,20 +1520,19 @@ class Build(
|
||||
# Assign the stock status
|
||||
output.set_status(status)
|
||||
|
||||
output.save(add_note=False)
|
||||
|
||||
deltas = {
|
||||
'status': status,
|
||||
'buildorder': self.pk,
|
||||
'buildorder': self,
|
||||
'quantity': float(output.quantity),
|
||||
}
|
||||
|
||||
if location:
|
||||
deltas['location'] = location.pk
|
||||
deltas['location'] = location
|
||||
|
||||
output.add_tracking_entry(
|
||||
with output.tracking_event(
|
||||
StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas=deltas
|
||||
)
|
||||
):
|
||||
output.save(add_note=False)
|
||||
|
||||
trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk)
|
||||
|
||||
|
||||
@@ -536,7 +536,7 @@ class BuildOutputScrapSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=stock_models.StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
help_text=_('Reason for scrapping build output(s)'),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
@@ -599,7 +599,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=stock_models.StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
@@ -1786,7 +1786,7 @@ class BuildConsumeSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=stock_models.StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
help_text=_('Optional notes for the stock consumption'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
|
||||
@@ -11,14 +11,20 @@ from django.urls import reverse
|
||||
from django_q.models import OrmQ
|
||||
from rest_framework import status
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
from build.status_codes import BuildStatus
|
||||
from common.models import Note
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from InvenTree.unit_test import (
|
||||
InvenTreeAPITestCase,
|
||||
stock_events,
|
||||
stock_history,
|
||||
stock_history_count,
|
||||
)
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.events import StockEvents
|
||||
from stock.models import StockItem, StockItemTracking, StockLocation, StockSortOrder
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
|
||||
@@ -2427,13 +2433,13 @@ class BuildConsumeTest(BuildAPITest):
|
||||
|
||||
consume_build_stock(self.build.pk, items=items, expected=expected)
|
||||
|
||||
n_tracking = StockItemTracking.objects.count()
|
||||
n_tracking = stock_history_count()
|
||||
n_stock = StockItem.objects.count()
|
||||
|
||||
# Simulate redelivery of the exact same task payload
|
||||
consume_build_stock(self.build.pk, items=items, expected=expected)
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
|
||||
self.assertEqual(stock_history_count(), n_tracking)
|
||||
self.assertEqual(StockItem.objects.count(), n_stock)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
@@ -2468,12 +2474,12 @@ class BuildConsumeTest(BuildAPITest):
|
||||
remaining = self.build.allocated_stock.get()
|
||||
self.assertEqual(remaining.quantity, 60)
|
||||
|
||||
n_tracking = StockItemTracking.objects.count()
|
||||
n_tracking = stock_history_count()
|
||||
|
||||
# Simulate redelivery of the exact same task payload
|
||||
consume_build_stock(self.build.pk, lines=lines, expected=expected)
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
|
||||
self.assertEqual(stock_history_count(), n_tracking)
|
||||
|
||||
remaining.refresh_from_db()
|
||||
self.assertEqual(remaining.quantity, 60)
|
||||
@@ -2749,16 +2755,15 @@ class BuildConsumeTest(BuildAPITest):
|
||||
self.assertEqual(si.consumed_by, build)
|
||||
|
||||
self.assertFalse(
|
||||
StockItemTracking.objects.filter(
|
||||
item=si,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
stock_events(
|
||||
si, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=si,
|
||||
tracking_type=StockHistoryCode.BUILD_CONSUMED.value,
|
||||
deltas__buildorder=build.pk,
|
||||
stock_events(
|
||||
si,
|
||||
event_type=StockHistoryCode.BUILD_CONSUMED,
|
||||
additional_data__deltas__buildorder__pk=build.pk,
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -2786,18 +2791,15 @@ class BuildConsumeTest(BuildAPITest):
|
||||
self.assertEqual(consumed_item.quantity, required_quantity)
|
||||
self.assertNotEqual(consumed_item.pk, stock_item.pk)
|
||||
|
||||
# Split tracking entries: one on the new (split-off) item, one on the original
|
||||
# The new (split-off) item records its creation (as a child of the original)
|
||||
created = stock_history(consumed_item).first()
|
||||
self.assertEqual(created.action, AuditLogEntry.Action.CREATE)
|
||||
self.assertEqual(created.changes_dict['parent'], [None, stock_item.pk])
|
||||
|
||||
# The original item records the split
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=consumed_item,
|
||||
tracking_type=StockHistoryCode.SPLIT_FROM_PARENT.value,
|
||||
deltas__stockitem=stock_item.pk,
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=stock_item,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
stock_events(
|
||||
stock_item, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -2813,10 +2815,10 @@ class BuildConsumeTest(BuildAPITest):
|
||||
|
||||
# Consumption is recorded against the new (split-off) item
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=consumed_item,
|
||||
tracking_type=StockHistoryCode.BUILD_CONSUMED.value,
|
||||
deltas__buildorder=build.pk,
|
||||
stock_events(
|
||||
consumed_item,
|
||||
event_type=StockHistoryCode.BUILD_CONSUMED,
|
||||
additional_data__deltas__buildorder__pk=build.pk,
|
||||
).exists()
|
||||
)
|
||||
else:
|
||||
@@ -2826,16 +2828,15 @@ class BuildConsumeTest(BuildAPITest):
|
||||
|
||||
# No split occurred - consumption is recorded directly against this item
|
||||
self.assertFalse(
|
||||
StockItemTracking.objects.filter(
|
||||
item=stock_item,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
stock_events(
|
||||
stock_item, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=stock_item,
|
||||
tracking_type=StockHistoryCode.BUILD_CONSUMED.value,
|
||||
deltas__buildorder=build.pk,
|
||||
stock_events(
|
||||
stock_item,
|
||||
event_type=StockHistoryCode.BUILD_CONSUMED,
|
||||
additional_data__deltas__buildorder__pk=build.pk,
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -2951,17 +2952,17 @@ class BuildConsumeTest(BuildAPITest):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=component_item,
|
||||
tracking_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY.value,
|
||||
deltas__stockitem=output_item.pk,
|
||||
stock_events(
|
||||
component_item,
|
||||
event_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||
additional_data__deltas__stockitem__pk=output_item.pk,
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=output_item,
|
||||
tracking_type=StockHistoryCode.INSTALLED_CHILD_ITEM.value,
|
||||
deltas__stockitem=component_item.pk,
|
||||
stock_events(
|
||||
output_item,
|
||||
event_type=StockHistoryCode.INSTALLED_CHILD_ITEM,
|
||||
additional_data__deltas__stockitem__pk=component_item.pk,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
@@ -152,3 +152,225 @@ class BuildAuditAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]['model_label'], 'Build Order')
|
||||
|
||||
|
||||
class BuildStockAuditTest(BuildTestBase):
|
||||
"""Stock operations performed by a build order record a single audit entry per stock item."""
|
||||
|
||||
def stock_entries(self, item, n=0):
|
||||
"""Return the audit entries for a stock item (after the first n entries)."""
|
||||
from stock.models import StockItem
|
||||
|
||||
return list(
|
||||
AuditLogEntry.objects.filter(
|
||||
content_type=StockItem.get_content_type(), object_pk=str(item.pk)
|
||||
).order_by('pk')[n:]
|
||||
)
|
||||
|
||||
def test_create_output(self):
|
||||
"""Creating build outputs records a single CREATE entry for each output."""
|
||||
from part.models import Part
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
# An assembly without any trackable components
|
||||
assembly = Part.objects.create(
|
||||
name='Simple Assembly', description='An assembly', assembly=True
|
||||
)
|
||||
build = Build.objects.create(part=assembly, quantity=5, reference='BO-9876')
|
||||
|
||||
outputs = build.create_build_output(5, user=self.user)
|
||||
|
||||
for output in outputs:
|
||||
[entry] = self.stock_entries(output)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_OUTPUT_CREATED)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['buildorder']['pk'], build.pk
|
||||
)
|
||||
|
||||
def test_create_serialized_outputs(self):
|
||||
"""Creating serialized build outputs records a single CREATE entry for each output."""
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
self.build.part.trackable = True
|
||||
self.build.part.save()
|
||||
|
||||
outputs = self.build.create_build_output(
|
||||
3, serials=['1001', '1002', '1003'], user=self.user
|
||||
)
|
||||
|
||||
self.assertEqual(outputs.count(), 3)
|
||||
|
||||
for output in outputs:
|
||||
[entry] = self.stock_entries(output)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_OUTPUT_CREATED)
|
||||
self.assertEqual(entry.changes_dict['serial'][1], output.serial)
|
||||
|
||||
def test_consume(self):
|
||||
"""Consuming allocated stock records a single entry for each affected item."""
|
||||
from build.models import BuildItem
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
BuildItem.objects.create(
|
||||
build_line=self.line_1, stock_item=self.stock_1_1, quantity=3
|
||||
)
|
||||
BuildItem.objects.create(
|
||||
build_line=self.line_1, stock_item=self.stock_1_2, quantity=10
|
||||
)
|
||||
|
||||
n_1 = len(self.stock_entries(self.stock_1_1))
|
||||
n_2 = len(self.stock_entries(self.stock_1_2))
|
||||
|
||||
self.build.complete_allocations(
|
||||
BuildItem.objects.filter(build_line__build=self.build), user=self.user
|
||||
)
|
||||
|
||||
# Fully consumed item
|
||||
[entry] = self.stock_entries(self.stock_1_1, n_1)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_CONSUMED)
|
||||
self.assertEqual(entry.changes_dict['consumed_by'], [None, self.build.pk])
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
|
||||
# Partially consumed item (split)
|
||||
[entry] = self.stock_entries(self.stock_1_2, n_2)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.SPLIT_CHILD_ITEM)
|
||||
self.assertEqual(entry.changes_dict['quantity'], ['100.00000', '90.00000'])
|
||||
|
||||
# The split item is created (and consumed)
|
||||
split = StockItem.objects.get(parent=self.stock_1_2)
|
||||
|
||||
[entry] = self.stock_entries(split)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_CONSUMED)
|
||||
self.assertEqual(entry.changes_dict['consumed_by'][1], self.build.pk)
|
||||
|
||||
def test_complete_output(self):
|
||||
"""Completing a build output records a single entry."""
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
n = len(self.stock_entries(self.output_1))
|
||||
|
||||
self.build.complete_build_output(self.output_1, self.user)
|
||||
|
||||
[entry] = self.stock_entries(self.output_1, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_OUTPUT_COMPLETED)
|
||||
self.assertEqual(entry.changes_dict['is_building'], [True, False])
|
||||
|
||||
def test_scrap_output(self):
|
||||
"""Scrapping a build output records a single entry."""
|
||||
from stock.models import StockLocation
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
location = StockLocation.objects.create(name='Scrap')
|
||||
|
||||
n = len(self.stock_entries(self.output_1))
|
||||
|
||||
self.build.scrap_build_output(
|
||||
self.output_1,
|
||||
self.output_1.quantity,
|
||||
location,
|
||||
user=self.user,
|
||||
notes='Scrapped',
|
||||
discard_allocations=True,
|
||||
)
|
||||
|
||||
[entry] = self.stock_entries(self.output_1, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.BUILD_OUTPUT_REJECTED)
|
||||
self.assertEqual(entry.notes, 'Scrapped')
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'],
|
||||
[StockStatus.OK.value, StockStatus.REJECTED.value],
|
||||
)
|
||||
|
||||
def test_consume_install(self):
|
||||
"""Consuming trackable stock (installed into an output) records an entry for both items."""
|
||||
from build.models import BuildItem
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
component = StockItem.objects.create(
|
||||
part=self.sub_part_3, quantity=1, serial='SN-1'
|
||||
)
|
||||
|
||||
BuildItem.objects.create(
|
||||
build_line=self.line_3,
|
||||
stock_item=component,
|
||||
quantity=1,
|
||||
install_into=self.output_1,
|
||||
)
|
||||
|
||||
n_component = len(self.stock_entries(component))
|
||||
n_output = len(self.stock_entries(self.output_1))
|
||||
|
||||
self.build.complete_allocations(
|
||||
BuildItem.objects.filter(stock_item=component), user=self.user
|
||||
)
|
||||
|
||||
[entry] = self.stock_entries(component, n_component)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.INSTALLED_INTO_ASSEMBLY)
|
||||
self.assertEqual(entry.changes_dict['belongs_to'], [None, self.output_1.pk])
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['buildorder']['pk'], self.build.pk
|
||||
)
|
||||
|
||||
# Nothing changed for the output itself - the event is recorded regardless
|
||||
[entry] = self.stock_entries(self.output_1, n_output)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.INSTALLED_CHILD_ITEM)
|
||||
self.assertEqual(entry.changes_dict, {})
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['stockitem']['pk'], component.pk
|
||||
)
|
||||
|
||||
def test_consume_install_multiple(self):
|
||||
"""Installing multiple components into one output records an entry for each."""
|
||||
from build.models import BuildItem
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
components = [
|
||||
StockItem.objects.create(part=self.sub_part_3, quantity=1, serial=serial)
|
||||
for serial in ['SN-1', 'SN-2']
|
||||
]
|
||||
|
||||
for component in components:
|
||||
BuildItem.objects.create(
|
||||
build_line=self.line_3,
|
||||
stock_item=component,
|
||||
quantity=1,
|
||||
install_into=self.output_1,
|
||||
)
|
||||
|
||||
n_output = len(self.stock_entries(self.output_1))
|
||||
|
||||
self.build.complete_allocations(
|
||||
BuildItem.objects.filter(stock_item__in=components), user=self.user
|
||||
)
|
||||
|
||||
entries = self.stock_entries(self.output_1, n_output)
|
||||
|
||||
self.assertEqual(len(entries), 2)
|
||||
|
||||
for entry in entries:
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.INSTALLED_CHILD_ITEM)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(
|
||||
entry.additional_data['deltas']['stockitem']['pk'] for entry in entries
|
||||
),
|
||||
sorted(component.pk for component in components),
|
||||
)
|
||||
@@ -31,15 +31,11 @@ from InvenTree.unit_test import (
|
||||
InvenTreeTestCase,
|
||||
findOffloadedEvent,
|
||||
findOffloadedTask,
|
||||
stock_history_count,
|
||||
)
|
||||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
)
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from stock.status_codes import StockStatus
|
||||
from users.models import Owner
|
||||
|
||||
@@ -1540,7 +1536,7 @@ class BuildTaskTests(BuildTestBase):
|
||||
self.assertEqual(self.build.status, BuildStatus.COMPLETE)
|
||||
|
||||
n_consumed = StockItem.objects.filter(consumed_by=self.build).count()
|
||||
n_tracking = StockItemTracking.objects.count()
|
||||
n_tracking = stock_history_count()
|
||||
|
||||
# A redelivered task run must skip based on the (locked) database state
|
||||
with mock.patch('build.tasks.trigger_event') as trigger:
|
||||
@@ -1550,7 +1546,7 @@ class BuildTaskTests(BuildTestBase):
|
||||
self.assertEqual(
|
||||
StockItem.objects.filter(consumed_by=self.build).count(), n_consumed
|
||||
)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
|
||||
self.assertEqual(stock_history_count(), n_tracking)
|
||||
|
||||
def test_allocate_stock_merges_quantities(self):
|
||||
"""Repeated allocations against the same (line, stock item) accumulate.
|
||||
@@ -1615,7 +1611,7 @@ class BuildTaskTests(BuildTestBase):
|
||||
self.assertEqual(self.build.completed, 10)
|
||||
self.assertFalse(self.output_1.is_building)
|
||||
|
||||
n_tracking = StockItemTracking.objects.count()
|
||||
n_tracking = stock_history_count()
|
||||
|
||||
# Run the task again (simulating a duplicated / redelivered task)
|
||||
build.tasks.complete_build_outputs(
|
||||
@@ -1630,7 +1626,7 @@ class BuildTaskTests(BuildTestBase):
|
||||
# and no additional stock history has been generated
|
||||
self.build.refresh_from_db()
|
||||
self.assertEqual(self.build.completed, 10)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
|
||||
self.assertEqual(stock_history_count(), n_tracking)
|
||||
|
||||
def test_complete_output_twice_rejected(self):
|
||||
"""Completing or scrapping an already-completed output must be rejected.
|
||||
|
||||
@@ -1317,23 +1317,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'units': _('days'),
|
||||
'validator': [int, MinValueValidator(30)],
|
||||
},
|
||||
'STOCK_TRACKING_DELETE_OLD_ENTRIES': {
|
||||
'name': _('Delete Old Stock Tracking Entries'),
|
||||
'description': _(
|
||||
'Delete stock tracking entries older than the specified number of days'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_TRACKING_DELETE_DAYS': {
|
||||
'name': _('Stock Tracking Deletion Interval'),
|
||||
'description': _(
|
||||
'Stock tracking entries will be deleted after specified number of days'
|
||||
),
|
||||
'default': 365,
|
||||
'units': _('days'),
|
||||
'validator': [int, MinValueValidator(30)],
|
||||
},
|
||||
'AUDIT_LOG_ENABLED': {
|
||||
'name': _('Enable Audit Log'),
|
||||
'description': _('Record a history of changes made to supported models'),
|
||||
|
||||
@@ -252,7 +252,7 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'DISPLAY_HISTORY_TAB': {
|
||||
'name': _('Show Change History'),
|
||||
'description': _(
|
||||
'Display the change history (audit log) panel on detail pages (staff users only)'
|
||||
'Display the change history (audit log) panel on detail pages'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Order model definitions."""
|
||||
|
||||
import copy
|
||||
from contextlib import ExitStack
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
@@ -32,7 +33,13 @@ import order.validators
|
||||
import report.mixins
|
||||
import stock.models
|
||||
import users.models as UserModels
|
||||
from audit.helpers import audited_bulk_update
|
||||
from audit.helpers import (
|
||||
audit_event,
|
||||
audited_bulk_update,
|
||||
copy_related_cache,
|
||||
record_created,
|
||||
write_batched,
|
||||
)
|
||||
from build.status_codes import BuildStatus
|
||||
from common.currency import currency_code_default
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
@@ -1139,22 +1146,16 @@ class PurchaseOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
# List of stock items which have been created
|
||||
stock_items: list[stock.models.StockItem] = []
|
||||
|
||||
# Per-item 'note' text, index-aligned with stock_items - StockItem no longer
|
||||
# has its own 'notes' field, so this is threaded through to each item's
|
||||
# RECEIVED_AGAINST_PURCHASE_ORDER tracking entry instead (see below)
|
||||
stock_item_notes: list[str] = []
|
||||
|
||||
# List of stock items to bulk create
|
||||
bulk_create_items: list[stock.models.StockItem] = []
|
||||
|
||||
# Notes for bulk_create_items, appended in lockstep - bulk_create_and_fetch()
|
||||
# re-fetches fresh instances from the database, so any note has to be tracked
|
||||
# positionally here rather than stashed on the (discarded) unsaved instance
|
||||
# positionally here rather than stashed on the (discarded) unsaved instance.
|
||||
# StockItem has no 'notes' field, so the note is recorded against each item's
|
||||
# RECEIVED_AGAINST_PURCHASE_ORDER tracking event instead
|
||||
bulk_create_notes: list[str] = []
|
||||
|
||||
# List of tracking entries to create
|
||||
tracking_entries: list[stock.models.StockItemTracking] = []
|
||||
|
||||
# List of line items to update
|
||||
line_items_to_update: list[PurchaseOrderLineItem] = []
|
||||
|
||||
@@ -1326,14 +1327,22 @@ class PurchaseOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
|
||||
# Now, create the new stock items
|
||||
if serialize:
|
||||
# Resolve the (possibly custom) status *before* the items are created,
|
||||
# so that each new item is created (and recorded) with the selected status
|
||||
status_item = stock.models.StockItem()
|
||||
status_item.set_status(status, custom_values=custom_stock_status_values)
|
||||
|
||||
new_items = stock.models.StockItem._create_serial_numbers(
|
||||
serials=serials, **stock_data
|
||||
serials=serials,
|
||||
tracking=lambda item, note=note: self._receive_tracking_event(
|
||||
item, user, note
|
||||
),
|
||||
status=status_item.status,
|
||||
status_custom_key=status_item.status_custom_key,
|
||||
**stock_data,
|
||||
)
|
||||
|
||||
for new_item in new_items:
|
||||
new_item.set_status(
|
||||
status, custom_values=custom_stock_status_values
|
||||
)
|
||||
# run validation for serialized items plugin.validate_batch_code
|
||||
new_item.validate_batch_code()
|
||||
|
||||
@@ -1341,7 +1350,6 @@ class PurchaseOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
new_item.run_plugin_validation()
|
||||
|
||||
stock_items.append(new_item)
|
||||
stock_item_notes.append(note)
|
||||
|
||||
else:
|
||||
new_item = stock.models.StockItem(**stock_data, serial='', parent=None)
|
||||
@@ -1368,29 +1376,23 @@ class PurchaseOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
bulk_create_and_fetch(stock.models.StockItem, bulk_create_items)
|
||||
)
|
||||
|
||||
stock_items.extend(new_items)
|
||||
stock_item_notes.extend(bulk_create_notes)
|
||||
# Retain the cached related objects (avoid per-item lookups)
|
||||
for original, new_item in zip(bulk_create_items, new_items, strict=True):
|
||||
copy_related_cache(original, new_item)
|
||||
|
||||
# Generate a new tracking entry for each stock item
|
||||
for item, item_note in zip(stock_items, stock_item_notes, strict=True):
|
||||
tracking_entries.append(
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
user,
|
||||
deltas={
|
||||
'location': item.location.pk if item.location else None,
|
||||
'purchaseorder': self.pk,
|
||||
'quantity': float(item.quantity),
|
||||
},
|
||||
notes=item_note,
|
||||
commit=False,
|
||||
)
|
||||
# Record a tracking event for each new item
|
||||
item_notes = dict(
|
||||
zip([item.pk for item in new_items], bulk_create_notes, strict=True)
|
||||
)
|
||||
|
||||
# Bulk create new tracking entries for each item
|
||||
stock.models.StockItemTracking.objects.bulk_create(
|
||||
tracking_entries, batch_size=250
|
||||
)
|
||||
stock.models.StockItem._record_created_items(
|
||||
new_items,
|
||||
tracking=lambda item: self._receive_tracking_event(
|
||||
item, user, item_notes[item.pk]
|
||||
),
|
||||
)
|
||||
|
||||
stock_items.extend(new_items)
|
||||
|
||||
# Update received quantity for each line item
|
||||
audited_bulk_update(PurchaseOrderLineItem, line_items_to_update, ['received'])
|
||||
@@ -1425,6 +1427,19 @@ class PurchaseOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
pk__in=[item.pk for item in stock_items]
|
||||
)
|
||||
|
||||
def _receive_tracking_event(self, item, user, notes: str) -> dict:
|
||||
"""Return the tracking event for a stock item received against this order."""
|
||||
return {
|
||||
'entry_type': StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
'user': user,
|
||||
'notes': notes,
|
||||
'deltas': {
|
||||
'location': item.location,
|
||||
'purchaseorder': self,
|
||||
'quantity': float(item.quantity),
|
||||
},
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(
|
||||
self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs
|
||||
@@ -2974,7 +2989,7 @@ class SalesOrderShipment(
|
||||
|
||||
Notes:
|
||||
This unrolls what would otherwise be a per-allocation call, so that the underlying
|
||||
StockItem, StockItemTracking, SalesOrderLineItem and SalesOrderAllocation writes can
|
||||
StockItem, audit log, SalesOrderLineItem and SalesOrderAllocation writes can
|
||||
be batched into a handful of bulk queries instead of several per allocation.
|
||||
"""
|
||||
import part.tasks
|
||||
@@ -3057,6 +3072,10 @@ class SalesOrderShipment(
|
||||
|
||||
for i, (source, placeholder, quantity) in enumerate(split_items):
|
||||
persisted = new_stock_items[i]
|
||||
|
||||
# Retain the cached related objects (avoid per-item lookups)
|
||||
copy_related_cache(source, persisted)
|
||||
|
||||
split_item_map[id(placeholder)] = persisted
|
||||
split_items[i] = (source, persisted, quantity)
|
||||
|
||||
@@ -3068,69 +3087,53 @@ class SalesOrderShipment(
|
||||
for allocation in allocations_to_update:
|
||||
allocation.item = split_item_map[id(allocation.item)]
|
||||
|
||||
tracking_entries = []
|
||||
split_events = []
|
||||
customer_events = []
|
||||
split_events = [
|
||||
((), {'id': new_item.pk, 'parent': source_item.pk})
|
||||
for source_item, new_item, _quantity in split_items
|
||||
]
|
||||
|
||||
# Split stock items for "split_items"
|
||||
for source_item, new_item, quantity in split_items:
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=new_item.pk,
|
||||
part_id=new_item.part_id,
|
||||
tracking_type=StockHistoryCode.SPLIT_FROM_PARENT.value,
|
||||
user=user,
|
||||
deltas={'stockitem': source_item.pk, 'quantity': float(quantity)},
|
||||
)
|
||||
)
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=source_item.pk,
|
||||
part_id=source_item.part_id,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
user=user,
|
||||
deltas={
|
||||
'removed': float(quantity),
|
||||
'quantity': float(source_item.quantity),
|
||||
customer_events = [
|
||||
((), {'id': target_item.pk, 'customer': customer.pk if customer else None})
|
||||
for target_item, _quantity in shipped_items
|
||||
]
|
||||
|
||||
# Record the operation in the audit log - a single entry for each stock item
|
||||
with write_batched(), ExitStack() as events:
|
||||
|
||||
def audit(item, code, deltas):
|
||||
"""Record a named event against the provided stock item."""
|
||||
events.enter_context(audit_event(item, code, deltas=deltas, actor=user))
|
||||
|
||||
for source_item, new_item, quantity in split_items:
|
||||
audit(
|
||||
source_item,
|
||||
StockHistoryCode.SPLIT_CHILD_ITEM,
|
||||
{
|
||||
'stockitem': new_item,
|
||||
'removed': quantity,
|
||||
'quantity': source_item.quantity,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
split_events.append(((), {'id': new_item.pk, 'parent': source_item.pk}))
|
||||
|
||||
# Ship stock items for "shipped_items"
|
||||
for target_item, quantity in shipped_items:
|
||||
deltas = {'quantity': float(quantity), 'salesorder': order.pk}
|
||||
|
||||
if customer is not None:
|
||||
deltas['customer'] = customer.pk
|
||||
deltas['customer_name'] = customer.name
|
||||
|
||||
tracking_entries.append(
|
||||
stock.models.StockItemTracking(
|
||||
item_id=target_item.pk,
|
||||
part_id=target_item.part_id,
|
||||
tracking_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER.value,
|
||||
user=user,
|
||||
deltas=deltas,
|
||||
for target_item, quantity in shipped_items:
|
||||
audit(
|
||||
target_item,
|
||||
StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER,
|
||||
{'quantity': quantity, 'salesorder': order, 'customer': customer},
|
||||
)
|
||||
|
||||
# Newly split items are recorded as created (with the shipment event)
|
||||
record_created(stock.models.StockItem, new_stock_items)
|
||||
|
||||
# Flush all StockItem field changes (quantity reductions, and shipment details)
|
||||
# Note: Newly split items were created with their final state (no update required),
|
||||
# and any other shipped item is one of the (existing) items drawn from
|
||||
audited_bulk_update(
|
||||
stock.models.StockItem,
|
||||
list(seen_stock_items.values()),
|
||||
['quantity', 'sales_order', 'customer', 'location'],
|
||||
)
|
||||
|
||||
customer_events.append((
|
||||
(),
|
||||
{'id': target_item.pk, 'customer': customer.pk if customer else None},
|
||||
))
|
||||
|
||||
# Flush all StockItem field changes (quantity reductions, and shipment details)
|
||||
stock.models.StockItem.objects.bulk_update(
|
||||
seen_stock_items.values(), ['quantity']
|
||||
)
|
||||
stock.models.StockItem.objects.bulk_update(
|
||||
[item for item, _ in shipped_items], ['sales_order', 'customer', 'location']
|
||||
)
|
||||
|
||||
stock.models.StockItemTracking.objects.bulk_create(tracking_entries)
|
||||
|
||||
# Update sales order lines for "seen_lines"
|
||||
audited_bulk_update(SalesOrderLineItem, list(seen_lines.values()), ['shipped'])
|
||||
|
||||
@@ -3658,31 +3661,29 @@ class ReturnOrder(AuditedOrderMixin, TotalPriceMixin, Order):
|
||||
|
||||
deltas = {
|
||||
'status': status,
|
||||
'returnorder': self.pk,
|
||||
'location': location.pk,
|
||||
'returnorder': self,
|
||||
'location': location,
|
||||
'quantity': float(line.quantity),
|
||||
}
|
||||
|
||||
if stock_item.customer:
|
||||
deltas['customer'] = stock_item.customer.pk
|
||||
deltas['customer'] = stock_item.customer
|
||||
|
||||
# Update the StockItem
|
||||
stock_item.set_status(status)
|
||||
stock_item.location = location
|
||||
stock_item.customer = None
|
||||
stock_item.sales_order = None
|
||||
stock_item.save(add_note=False)
|
||||
stock_item.clearAllocations()
|
||||
|
||||
# Add a tracking entry to the StockItem
|
||||
stock_item.add_tracking_entry(
|
||||
with stock_item.tracking_event(
|
||||
StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
|
||||
user,
|
||||
notes=kwargs.get('note', ''),
|
||||
deltas=deltas,
|
||||
location=location,
|
||||
returnorder=self,
|
||||
)
|
||||
):
|
||||
stock_item.save(add_note=False)
|
||||
|
||||
stock_item.clearAllocations()
|
||||
|
||||
# Update the LineItem
|
||||
line.received_date = InvenTree.helpers.current_date()
|
||||
|
||||
@@ -19,6 +19,7 @@ import order.models
|
||||
import part.filters as part_filters
|
||||
import stock.models
|
||||
import stock.serializers
|
||||
from audit.helpers import write_batched
|
||||
from common.settings import get_global_setting
|
||||
from company.serializers import (
|
||||
AddressBriefSerializer,
|
||||
@@ -53,7 +54,6 @@ from order.status_codes import (
|
||||
)
|
||||
from part.serializers import PartBriefSerializer
|
||||
from plugin.base.event.events import batch_events
|
||||
from stock.models import batch_tracking_entries
|
||||
from stock.status_codes import StockStatus
|
||||
from users.serializers import OwnerSerializer, UserSerializer
|
||||
|
||||
@@ -927,7 +927,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
max_length=stock.models.StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=stock.models.StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
label=_('Note'),
|
||||
help_text=_('Additional note for incoming stock items'),
|
||||
required=False,
|
||||
@@ -1127,7 +1127,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
items = order.receive_line_items(
|
||||
@@ -2344,7 +2344,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
max_length=stock.models.StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=stock.models.StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
label=_('Note'),
|
||||
help_text=_('Additional note for incoming stock items'),
|
||||
required=False,
|
||||
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
@@ -24,7 +25,7 @@ from common.currency import currency_codes
|
||||
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
||||
from common.settings import set_global_setting
|
||||
from company.models import Address, Company, Contact, SupplierPart, SupplierPriceBreak
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, stock_history
|
||||
from order import models
|
||||
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
|
||||
from order.serializers import TransferOrderSerialAllocationSerializer
|
||||
@@ -38,7 +39,7 @@ from order.status_codes import (
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockItemTracking, StockLocation, StockSortOrder
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
from users.models import Owner
|
||||
|
||||
@@ -1956,7 +1957,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
'batch_code': stock_field('batch'),
|
||||
'packaging': stock_field('packaging'),
|
||||
'barcode': stock_field('barcode_data'),
|
||||
'note': StockItemTracking.NOTES_MAX_LENGTH,
|
||||
'note': StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
}
|
||||
|
||||
line_data = {'line_item': 1, 'quantity': 10}
|
||||
@@ -2166,13 +2167,15 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
self.assertEqual(item['status'], StockStatus.QUARANTINED)
|
||||
|
||||
stock_item = StockItem.objects.get(pk=item['pk'])
|
||||
# Check that the item has tracking entries
|
||||
self.assertEqual(stock_item.tracking_info.count(), 1)
|
||||
entry = stock_item.tracking_info.first()
|
||||
self.assertEqual(entry.deltas['quantity'], stock_item.quantity)
|
||||
self.assertEqual(entry.user, self.user)
|
||||
# Check that the item has a single stock history entry (its creation)
|
||||
self.assertEqual(stock_history(stock_item).count(), 1)
|
||||
entry = stock_history(stock_item).first()
|
||||
self.assertEqual(
|
||||
entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
Decimal(entry.changes_dict['quantity'][1]), stock_item.quantity
|
||||
)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(
|
||||
entry.event_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
)
|
||||
|
||||
# Check that the order has been completed
|
||||
@@ -2207,10 +2210,10 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
stock_item = StockItem.objects.get(pk=response[0]['pk'])
|
||||
|
||||
self.assertEqual(stock_item.tracking_info.count(), 1)
|
||||
entry = stock_item.tracking_info.first()
|
||||
self.assertEqual(stock_history(stock_item).count(), 1)
|
||||
entry = stock_history(stock_item).first()
|
||||
self.assertEqual(
|
||||
entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
entry.event_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
)
|
||||
self.assertEqual(entry.notes, 'Damaged box, 2 units short')
|
||||
|
||||
@@ -2239,10 +2242,86 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
for i in range(200, 203):
|
||||
item = StockItem.objects.get(serial_int=i)
|
||||
self.assertEqual(item.tracking_info.count(), 1)
|
||||
entry = item.tracking_info.first()
|
||||
self.assertEqual(stock_history(item).count(), 1)
|
||||
entry = stock_history(item).first()
|
||||
self.assertEqual(
|
||||
entry.event_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
)
|
||||
self.assertEqual(entry.notes, 'Received via serialized batch')
|
||||
|
||||
def test_receive_serialized_status(self):
|
||||
"""Test that the selected status (including custom status values) is applied to received serialized items.
|
||||
|
||||
Regression test: the status was previously only applied to the (already created)
|
||||
serialized items in memory, and was never saved to the database.
|
||||
"""
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 3,
|
||||
'serial_numbers': '300+',
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
for i in range(300, 303):
|
||||
item = StockItem.objects.get(serial_int=i)
|
||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# The status is recorded against the creation of the item
|
||||
[entry] = stock_history(item)
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'], [None, StockStatus.DAMAGED.value]
|
||||
)
|
||||
|
||||
# A custom status value is also applied (and recorded)
|
||||
custom_status = InvenTreeCustomUserStateModel.objects.create(
|
||||
key=1055,
|
||||
name='Damaged (cosmetic)',
|
||||
label='Damaged (cosmetic)',
|
||||
color='warning',
|
||||
logical_key=StockStatus.DAMAGED.value,
|
||||
model=ContentType.objects.get_for_model(StockItem),
|
||||
reference_status='StockStatus',
|
||||
)
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 3,
|
||||
'serial_numbers': '400+',
|
||||
'status': custom_status.key,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
for i in range(400, 403):
|
||||
item = StockItem.objects.get(serial_int=i)
|
||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||
self.assertEqual(item.status_custom_key, custom_status.key)
|
||||
self.assertEqual(item.get_custom_status(), custom_status.key)
|
||||
|
||||
[entry] = stock_history(item)
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'], [None, StockStatus.DAMAGED.value]
|
||||
)
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status_custom_key'], [None, custom_status.key]
|
||||
)
|
||||
|
||||
def test_bulk_receive_query_benchmark(self):
|
||||
"""Benchmark: measure the number of DB queries required to receive 100 line items at once."""
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None)
|
||||
@@ -4001,7 +4080,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
rma = models.ReturnOrder.objects.first()
|
||||
url = reverse('api-return-order-receive', kwargs={'pk': rma.pk})
|
||||
|
||||
max_length = StockItemTracking.NOTES_MAX_LENGTH
|
||||
max_length = StockItem.TRACKING_NOTES_MAX_LENGTH
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
@@ -4222,7 +4301,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
# Get a reference to one of the stock items
|
||||
stock_item = rma.lines.first().item
|
||||
|
||||
n_tracking = stock_item.tracking_info.count()
|
||||
n_tracking = stock_history(stock_item).count()
|
||||
|
||||
# Receive items successfully
|
||||
data = receive(
|
||||
@@ -4234,16 +4313,19 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
self.assertTrue(line.received)
|
||||
self.assertIsNotNone(line.received_date)
|
||||
|
||||
# A single tracking entry should have been added to the item
|
||||
self.assertEqual(stock_item.tracking_info.count(), n_tracking + 1)
|
||||
# A single audit entry should have been added to the item
|
||||
self.assertEqual(stock_history(stock_item).count(), n_tracking + 1)
|
||||
|
||||
tracking_entry = stock_item.tracking_info.last()
|
||||
deltas = tracking_entry.deltas
|
||||
entry = stock_history(stock_item).last()
|
||||
self.assertEqual(
|
||||
entry.event_type, StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER
|
||||
)
|
||||
|
||||
self.assertEqual(deltas['status'], StockStatus.QUARANTINED)
|
||||
self.assertEqual(deltas['customer'], customer.pk)
|
||||
self.assertEqual(deltas['location'], 1)
|
||||
self.assertEqual(deltas['returnorder'], rma.pk)
|
||||
# Field changes are recorded as such (and not duplicated in the deltas)
|
||||
self.assertEqual(entry.changes_dict['status'][1], StockStatus.QUARANTINED)
|
||||
self.assertEqual(entry.changes_dict['customer'], [customer.pk, None])
|
||||
self.assertEqual(entry.changes_dict['location'][1], 1)
|
||||
self.assertEqual(entry.additional_data['deltas']['returnorder']['pk'], rma.pk)
|
||||
|
||||
def test_receive_untracked(self):
|
||||
"""Test that we can receive untracked items against a ReturnOrder.
|
||||
|
||||
@@ -264,6 +264,23 @@ class PurchaseOrderAuditTest(OrderAuditTestBase):
|
||||
self.assertEqual(new, line.received + 3)
|
||||
self.assertEqual(entry.parent_object_pk, str(po.pk))
|
||||
|
||||
# Each received stock item records a single entry (creation, with the event)
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
items = StockItem.objects.filter(purchase_order=po)
|
||||
self.assertEqual(items.count(), 2)
|
||||
|
||||
for item in items:
|
||||
[entry] = self.entries(StockItem, item.pk)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(
|
||||
entry.event_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(entry.changes_dict['location'][1], location.pk)
|
||||
|
||||
|
||||
class SalesOrderAuditTest(OrderAuditTestBase):
|
||||
"""Audit tests for SalesOrder, line items, extra lines and shipments."""
|
||||
@@ -412,6 +429,27 @@ class SalesOrderAuditTest(OrderAuditTestBase):
|
||||
|
||||
self.assertIsNone(shipment_entry.changes_dict['shipment_date'][0])
|
||||
|
||||
# Stock items record a single entry each
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
[source_entry] = self.entries(
|
||||
StockItem, self.stock.pk, event_type__isnull=False
|
||||
).exclude(event_type=StockHistoryCode.CREATED)
|
||||
|
||||
self.assertEqual(source_entry.event_type, StockHistoryCode.SPLIT_CHILD_ITEM)
|
||||
self.assertEqual(source_entry.actor, self.user)
|
||||
|
||||
shipped = StockItem.objects.get(parent=self.stock)
|
||||
|
||||
[shipped_entry] = self.entries(StockItem, shipped.pk)
|
||||
|
||||
self.assertEqual(shipped_entry.action, ACTION.CREATE)
|
||||
self.assertEqual(
|
||||
shipped_entry.event_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
|
||||
)
|
||||
self.assertEqual(shipped_entry.changes_dict['customer'][1], self.customer.pk)
|
||||
|
||||
|
||||
class ReturnOrderAuditTest(OrderAuditTestBase):
|
||||
"""Audit tests for ReturnOrder, line items and extra lines."""
|
||||
@@ -484,6 +522,36 @@ class ReturnOrderAuditTest(OrderAuditTestBase):
|
||||
update.changes_dict['description'], ['Restocking fee', 'Inspection fee']
|
||||
)
|
||||
|
||||
def test_receive_stock(self):
|
||||
"""Receiving an item against a return order records a single entry."""
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
location = StockLocation.objects.create(name='Returns')
|
||||
|
||||
line = models.ReturnOrderLineItem.objects.create(
|
||||
order=self.order, item=self.item
|
||||
)
|
||||
|
||||
self.order.issue_order()
|
||||
|
||||
n = self.entries(StockItem, self.item.pk).count()
|
||||
|
||||
self.order.receive_line_item(line, location, self.user, note='Inspected')
|
||||
|
||||
[entry] = list(self.entries(StockItem, self.item.pk).order_by('pk')[n:])
|
||||
|
||||
self.assertEqual(
|
||||
entry.event_type, StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER
|
||||
)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(entry.notes, 'Inspected')
|
||||
self.assertEqual(entry.changes_dict['location'][1], location.pk)
|
||||
self.assertEqual(entry.changes_dict['status'][1], StockStatus.QUARANTINED.value)
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['returnorder']['pk'], self.order.pk
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderAuditTest(OrderAuditTestBase):
|
||||
"""Audit tests for TransferOrder and line items."""
|
||||
@@ -566,3 +634,17 @@ class TransferOrderAuditTest(OrderAuditTestBase):
|
||||
)
|
||||
self.assertNotIn('F(', str(entry.changes))
|
||||
self.assertEqual(entry.parent_object_pk, str(self.order.pk))
|
||||
|
||||
# The transferred stock records a single STOCK_MOVE event (linked to the order)
|
||||
from stock.models import StockItem
|
||||
from stock.status_codes import StockHistoryCode
|
||||
|
||||
moved = StockItem.objects.get(parent=self.stock)
|
||||
|
||||
[created] = self.entries(StockItem, moved.pk)
|
||||
|
||||
self.assertEqual(created.event_type, StockHistoryCode.SPLIT_FROM_PARENT)
|
||||
self.assertEqual(created.changes_dict['location'][1], self.target.pk)
|
||||
self.assertEqual(
|
||||
created.additional_data['deltas']['transferorder']['pk'], self.order.pk
|
||||
)
|
||||
@@ -24,6 +24,9 @@ from InvenTree.unit_test import (
|
||||
InvenTreeAPITestCase,
|
||||
InvenTreeTestCase,
|
||||
addUserPermission,
|
||||
stock_events,
|
||||
stock_history,
|
||||
stock_history_count,
|
||||
)
|
||||
from order.models import (
|
||||
SalesOrder,
|
||||
@@ -35,7 +38,7 @@ from order.models import (
|
||||
from order.serializers import SalesOrderShipmentAllocationSerializer
|
||||
from part.models import Part
|
||||
from stock.events import StockEvents
|
||||
from stock.models import StockItem, StockItemTracking, StockLocation
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.status_codes import StockHistoryCode
|
||||
from users.models import Owner
|
||||
|
||||
@@ -506,7 +509,7 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
self.assertFalse(self.shipment.check_can_complete(raise_error=False))
|
||||
|
||||
n_items = StockItem.objects.count()
|
||||
n_tracking = StockItemTracking.objects.count()
|
||||
n_tracking = stock_history_count()
|
||||
|
||||
# Simulate a duplicated task execution
|
||||
# (e.g. a second completion request submitted before the first task ran)
|
||||
@@ -519,7 +522,7 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
|
||||
# No additional stock items or tracking entries have been created
|
||||
self.assertEqual(StockItem.objects.count(), n_items)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
|
||||
self.assertEqual(stock_history_count(), n_tracking)
|
||||
|
||||
def test_shipment_many_items(self):
|
||||
"""Test completion of a shipment with many items.
|
||||
@@ -618,8 +621,8 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
url,
|
||||
expected_code=200,
|
||||
benchmark=True,
|
||||
max_query_time=5.0,
|
||||
max_query_count=120,
|
||||
max_query_time=10.0,
|
||||
max_query_count=150,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -674,25 +677,18 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(low_stock_tasks), 1)
|
||||
self.assertEqual(low_stock_tasks[0].args(), (part.pk,))
|
||||
|
||||
# Exactly one StockItemTracking entry should exist per split, per side
|
||||
# Exactly one 'split' event should exist per split (on the original item)
|
||||
self.assertEqual(
|
||||
StockItemTracking.objects.filter(
|
||||
tracking_type=StockHistoryCode.SPLIT_FROM_PARENT.value
|
||||
).count(),
|
||||
len(split_pks),
|
||||
)
|
||||
self.assertEqual(
|
||||
StockItemTracking.objects.filter(
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value
|
||||
).count(),
|
||||
stock_history_count(event_type=StockHistoryCode.SPLIT_CHILD_ITEM),
|
||||
len(split_pks),
|
||||
)
|
||||
|
||||
# Exactly one 'shipped' tracking entry should exist per allocated item
|
||||
# Exactly one 'shipped' event should exist per allocated item
|
||||
# (for split items, this is recorded against the creation of the new item)
|
||||
self.assertEqual(
|
||||
StockItemTracking.objects.filter(
|
||||
tracking_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER.value
|
||||
).count(),
|
||||
stock_history_count(
|
||||
event_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
|
||||
),
|
||||
N_ITEMS,
|
||||
)
|
||||
|
||||
@@ -704,15 +700,15 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(item.customer, customer)
|
||||
|
||||
self.assertFalse(
|
||||
StockItemTracking.objects.filter(
|
||||
item=item, tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value
|
||||
stock_events(
|
||||
item, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=item,
|
||||
tracking_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER.value,
|
||||
deltas__salesorder=so.pk,
|
||||
stock_events(
|
||||
item,
|
||||
event_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER,
|
||||
additional_data__deltas__salesorder__pk=so.pk,
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -735,24 +731,20 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(sample_new_item.quantity, 1)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=sample_new_item,
|
||||
tracking_type=StockHistoryCode.SPLIT_FROM_PARENT.value,
|
||||
deltas__stockitem=sample_original.pk,
|
||||
).exists()
|
||||
|
||||
# The new item records a single entry - its creation (as a child of the original),
|
||||
# with the 'shipped' event
|
||||
[created] = stock_history(sample_new_item)
|
||||
self.assertEqual(created.changes_dict['parent'], [None, sample_original.pk])
|
||||
self.assertEqual(
|
||||
created.event_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
|
||||
)
|
||||
self.assertEqual(created.additional_data['deltas']['salesorder']['pk'], so.pk)
|
||||
|
||||
# The original item records the split
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=sample_original,
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value,
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
StockItemTracking.objects.filter(
|
||||
item=sample_new_item,
|
||||
tracking_type=StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER.value,
|
||||
deltas__salesorder=so.pk,
|
||||
stock_events(
|
||||
sample_original, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
|
||||
@@ -4,5 +4,4 @@ It includes models for:
|
||||
|
||||
- StockLocation
|
||||
- StockItem
|
||||
- StockItemTracking
|
||||
"""
|
||||
@@ -3,15 +3,7 @@
|
||||
from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
|
||||
from common.admin_shared import ReadOnlyAdminMixin
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
StockLocationType,
|
||||
)
|
||||
from .models import StockItem, StockItemTestResult, StockLocation, StockLocationType
|
||||
|
||||
|
||||
class LocationInline(admin.TabularInline):
|
||||
@@ -78,17 +70,6 @@ class StockItemAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
@admin.register(StockItemTracking)
|
||||
class StockTrackingAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
|
||||
"""Admin class for StockTracking - read-only to preserve audit trail integrity."""
|
||||
|
||||
list_display = ('item', 'date', 'label')
|
||||
|
||||
search_fields = ['item__part__name', 'item__serial', 'notes']
|
||||
|
||||
autocomplete_fields = ['item']
|
||||
|
||||
|
||||
@admin.register(StockItemTestResult)
|
||||
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||
"""Admin class for StockItemTestResult."""
|
||||
|
||||
@@ -26,9 +26,7 @@ import InvenTree.helpers
|
||||
import InvenTree.permissions
|
||||
import stock.serializers as StockSerializers
|
||||
from build.models import Build
|
||||
from build.serializers import BuildSerializer
|
||||
from company.models import Company, ManufacturerPart, SupplierPart
|
||||
from company.serializers import CompanySerializer
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.api import (
|
||||
@@ -56,20 +54,11 @@ from InvenTree.mixins import (
|
||||
SerializerContextMixin,
|
||||
)
|
||||
from InvenTree.serializers import apply_duplicate_copy_options
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder
|
||||
from order.serializers import (
|
||||
PurchaseOrderSerializer,
|
||||
ReturnOrderSerializer,
|
||||
SalesOrderSerializer,
|
||||
TransferOrderSerializer,
|
||||
)
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.generators import generate_batch_code, generate_serial_number
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
StockLocationType,
|
||||
)
|
||||
@@ -1287,46 +1276,43 @@ class StockList(
|
||||
|
||||
original = duplicate['original']
|
||||
|
||||
# copy_history/copy_tests don't follow the copy_<x>_from() naming
|
||||
# convention (copyHistoryFrom/copyTestResultsFrom), so still need
|
||||
# copy_tests doesn't follow the copy_<x>_from() naming
|
||||
# convention (copyTestResultsFrom), so still needs
|
||||
# handling here - only copy_notes can go through the shared helper
|
||||
apply_duplicate_copy_options(item, duplicate, original, copy_notes=True)
|
||||
|
||||
if duplicate.get('copy_history', False):
|
||||
item.copyHistoryFrom(original)
|
||||
|
||||
if duplicate.get('copy_tests', False):
|
||||
item.copyTestResultsFrom(original)
|
||||
|
||||
with transaction.atomic():
|
||||
if serials:
|
||||
create_data = {**serializer.validated_data}
|
||||
|
||||
if status_value:
|
||||
# Resolve the (possibly custom) status before the items are created,
|
||||
# so that each item is created with the correct status
|
||||
status_item = StockItem()
|
||||
|
||||
if status_item.set_status(status_value):
|
||||
create_data['status'] = status_item.status
|
||||
create_data['status_custom_key'] = status_item.status_custom_key
|
||||
|
||||
# Create multiple serialized StockItem objects
|
||||
# (recording a tracking event for each new item)
|
||||
items = StockItem._create_serial_numbers(
|
||||
serials, **serializer.validated_data
|
||||
serials,
|
||||
tracking=lambda item: {
|
||||
'entry_type': StockHistoryCode.CREATED,
|
||||
'user': user,
|
||||
'deltas': {'status': status_value},
|
||||
'quantity': float(item.quantity),
|
||||
},
|
||||
**create_data,
|
||||
)
|
||||
|
||||
# Next, bulk-create stock tracking entries for the newly created items
|
||||
tracking = []
|
||||
|
||||
for item in items:
|
||||
if status_value and not item.compare_status(status_value):
|
||||
item.set_status(status_value)
|
||||
item.save()
|
||||
|
||||
apply_duplicate_options(item)
|
||||
|
||||
if entry := item.add_tracking_entry(
|
||||
StockHistoryCode.CREATED,
|
||||
user,
|
||||
deltas={'status': status_value},
|
||||
location=location,
|
||||
quantity=float(item.quantity),
|
||||
commit=False,
|
||||
):
|
||||
tracking.append(entry)
|
||||
|
||||
StockItemTracking.objects.bulk_create(tracking, batch_size=250)
|
||||
|
||||
# Annotate the stock items with part information
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(items)
|
||||
|
||||
@@ -1578,161 +1564,6 @@ class StockItemTestResultList(
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
class StockTrackingDetail(RetrieveAPI):
|
||||
"""Detail API endpoint for StockItemTracking model."""
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
|
||||
class StockTrackingOutputOptions(OutputConfiguration):
|
||||
"""Output options for StockItemTracking endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption(flag='item_detail'),
|
||||
InvenTreeOutputOption(flag='user_detail'),
|
||||
]
|
||||
|
||||
|
||||
class StockTrackingFilter(FilterSet):
|
||||
"""API filter options for the StockTrackingList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = StockItemTracking
|
||||
fields = ['item', 'user']
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Part Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include part variants.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and is only used to modify the behavior of the 'part' filter.
|
||||
- Refer to the 'filter_part' method for more information on how this works.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
label=_('Part'), queryset=Part.objects.all(), method='filter_part'
|
||||
)
|
||||
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter StockTracking entries by the linked part.
|
||||
|
||||
Note:
|
||||
- This filter behavior also takes into account the 'include_variants' filter, which determines whether or not to include part variants in the results.
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
return queryset.filter(part__in=part.get_descendants(include_self=True))
|
||||
else:
|
||||
return queryset.filter(part=part)
|
||||
|
||||
min_date = InvenTreeDateFilter(
|
||||
label=_('Date after'), field_name='date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
max_date = InvenTreeDateFilter(
|
||||
label=_('Date before'), field_name='date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
|
||||
class StockTrackingList(
|
||||
SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI
|
||||
):
|
||||
"""API endpoint for list view of StockItemTracking objects.
|
||||
|
||||
StockItemTracking objects are read-only
|
||||
(they are created by internal model functionality)
|
||||
|
||||
- GET: Return list of StockItemTracking objects
|
||||
"""
|
||||
|
||||
queryset = StockItemTracking.objects.all().prefetch_related('item', 'part')
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
filterset_class = StockTrackingFilter
|
||||
output_options = StockTrackingOutputOptions
|
||||
|
||||
def get_delta_model_map(self) -> dict:
|
||||
"""Return a mapping of delta models to their respective models and serializers.
|
||||
|
||||
This is used to generate additional context information for the historical data,
|
||||
with some attempt at caching so that we can reduce the number of database hits.
|
||||
"""
|
||||
return {
|
||||
'part': (Part, PartBriefSerializer),
|
||||
'location': (StockLocation, StockSerializers.LocationSerializer),
|
||||
'customer': (Company, CompanySerializer),
|
||||
'purchaseorder': (PurchaseOrder, PurchaseOrderSerializer),
|
||||
'salesorder': (SalesOrder, SalesOrderSerializer),
|
||||
'returnorder': (ReturnOrder, ReturnOrderSerializer),
|
||||
'transferorder': (TransferOrder, TransferOrderSerializer),
|
||||
'buildorder': (Build, BuildSerializer),
|
||||
'item': (StockItem, StockSerializers.StockItemSerializer),
|
||||
'stockitem': (StockItem, StockSerializers.StockItemSerializer),
|
||||
}
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""List all stock tracking entries."""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
else:
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
|
||||
data = serializer.data
|
||||
|
||||
delta_models = self.get_delta_model_map()
|
||||
|
||||
# Construct a set of related models we need to lookup for later
|
||||
related_model_lookups = {key: set() for key in delta_models}
|
||||
|
||||
# Run a first pass through the data to determine which related models we need to lookup
|
||||
for item in data:
|
||||
deltas = item['deltas'] or {}
|
||||
|
||||
for key in delta_models:
|
||||
if key in deltas:
|
||||
related_model_lookups[key].add(deltas[key])
|
||||
|
||||
for key in delta_models:
|
||||
model, serializer = delta_models[key]
|
||||
|
||||
# Fetch all related models in one go
|
||||
related_models = model.objects.filter(pk__in=related_model_lookups[key])
|
||||
|
||||
# Construct a mapping of pk -> serialized data
|
||||
related_data = {obj.pk: serializer(obj).data for obj in related_models}
|
||||
|
||||
# Now, update the data with the serialized data
|
||||
for item in data:
|
||||
deltas = item['deltas'] or {}
|
||||
|
||||
if key in deltas:
|
||||
item['deltas'][f'{key}_detail'] = related_data.get(deltas[key])
|
||||
|
||||
if page is not None:
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
return Response(data)
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering = '-date'
|
||||
|
||||
ordering_fields = ['date']
|
||||
|
||||
search_fields = ['notes']
|
||||
|
||||
|
||||
stock_api_urls = [
|
||||
path(
|
||||
'location/',
|
||||
@@ -1796,25 +1627,6 @@ stock_api_urls = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
# StockItemTracking API endpoints
|
||||
path(
|
||||
'track/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
StockTrackingDetail.as_view(),
|
||||
name='api-stock-tracking-detail',
|
||||
),
|
||||
# Stock tracking status code information
|
||||
path(
|
||||
'status/',
|
||||
StatusView.as_view(),
|
||||
{StatusView.MODEL_REF: StockHistoryCode},
|
||||
name='api-stock-tracking-status-codes',
|
||||
),
|
||||
path('', StockTrackingList.as_view(), name='api-stock-tracking-list'),
|
||||
]),
|
||||
),
|
||||
# Detail views for a single stock item
|
||||
path(
|
||||
'<int:pk>/',
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Copy legacy stock tracking entries (StockItemTracking) into the audit log (AuditLogEntry).
|
||||
|
||||
Each StockItemTracking row is converted to a single AuditLogEntry:
|
||||
|
||||
- The entry is recorded against the stock item (even if the item has since been deleted)
|
||||
- The parent reference is the part (so the entry is included in the history of the part)
|
||||
- The tracking type is recorded as the event type (StockHistoryCode values are unchanged)
|
||||
- 'EDITED' entries are recorded as regular updates (no event type)
|
||||
- Entries which record the creation of a stock item are recorded as CREATE entries
|
||||
- The user, date and notes are copied across
|
||||
- Tracking deltas are converted to audit log changes / deltas:
|
||||
- Status changes are recorded as field changes ('status' / 'status_custom_key')
|
||||
- Location changes (with a known previous location) are recorded as field changes
|
||||
- For CREATE entries, initial field values are recorded as field changes
|
||||
- References to other objects (by pk) are recorded as {model_type, pk, str} references
|
||||
- Any other deltas are copied as-is
|
||||
- The pk of the original tracking entry is retained in additional_data['tracking_id']
|
||||
|
||||
The legacy StockItemTracking rows are not modified (or deleted) by this migration.
|
||||
"""
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
# StockHistoryCode values (copied here, as migrations should not import application code)
|
||||
CREATED = 1
|
||||
EDITED = 5
|
||||
SPLIT_FROM_PARENT = 40
|
||||
CREATED_FROM_DISASSEMBLY = 47
|
||||
BUILD_OUTPUT_CREATED = 50
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70
|
||||
|
||||
# Tracking codes which record the creation of a stock item
|
||||
CREATE_CODES = {
|
||||
CREATED,
|
||||
SPLIT_FROM_PARENT,
|
||||
CREATED_FROM_DISASSEMBLY,
|
||||
BUILD_OUTPUT_CREATED,
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
}
|
||||
|
||||
# AuditLogEntry action codes (as defined by django-auditlog)
|
||||
ACTION_CREATE = 0
|
||||
ACTION_UPDATE = 1
|
||||
|
||||
# Delta keys which reference other objects (by pk)
|
||||
REFERENCE_KEYS = {
|
||||
'stockitem': ('stock', 'stockitem'),
|
||||
'location': ('stock', 'stocklocation'),
|
||||
'old_location': ('stock', 'stocklocation'),
|
||||
'stockitemlocation': ('stock', 'stocklocation'),
|
||||
'part': ('part', 'part'),
|
||||
'customer': ('company', 'company'),
|
||||
'buildorder': ('build', 'build'),
|
||||
'build_order': ('build', 'build'),
|
||||
'purchaseorder': ('order', 'purchaseorder'),
|
||||
'salesorder': ('order', 'salesorder'),
|
||||
'returnorder': ('order', 'returnorder'),
|
||||
'transferorder': ('order', 'transferorder'),
|
||||
}
|
||||
|
||||
# Delta keys which are recorded as field changes (rather than deltas)
|
||||
STATUS_KEYS = ('status', 'status_logical', 'old_status', 'old_status_logical')
|
||||
|
||||
# For CREATE entries, these deltas record the initial value of the stock item field
|
||||
CREATE_FIELD_KEYS = ('quantity', 'location', 'serial', 'batch')
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def format_quantity(quantity) -> str:
|
||||
"""Format a stock quantity for display (e.g. '10', '2.5')."""
|
||||
try:
|
||||
value = Decimal(str(quantity)).normalize()
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return str(quantity)
|
||||
|
||||
# Remove exponent notation (e.g. 1E+1)
|
||||
return format(value, 'f')
|
||||
|
||||
|
||||
def stock_item_str(item) -> str:
|
||||
"""Return a string representation of a stock item.
|
||||
|
||||
Note: This approximates StockItem.__str__ - historical models do not provide model
|
||||
methods, and the configurable part name format cannot be applied here.
|
||||
"""
|
||||
if item.serial:
|
||||
return f'{item.part.name} #{item.serial}'
|
||||
|
||||
return f'{format_quantity(item.quantity)} x {item.part.name}'
|
||||
|
||||
|
||||
def reference_str(model_type: str, instance) -> str:
|
||||
"""Return a string representation for a referenced object."""
|
||||
if model_type == 'stock.stockitem':
|
||||
return stock_item_str(instance)
|
||||
|
||||
if model_type == 'stock.stocklocation':
|
||||
return instance.pathstring or instance.name
|
||||
|
||||
if model_type in (
|
||||
'build.build',
|
||||
'order.purchaseorder',
|
||||
'order.salesorder',
|
||||
'order.returnorder',
|
||||
'order.transferorder',
|
||||
):
|
||||
return instance.reference
|
||||
|
||||
return instance.name
|
||||
|
||||
|
||||
def to_pk(value):
|
||||
"""Convert a delta value to a primary key (or None)."""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def convert_entry(entry, references: dict) -> dict:
|
||||
"""Convert a single StockItemTracking entry into AuditLogEntry field values."""
|
||||
code = entry.tracking_type
|
||||
deltas = dict(entry.deltas or {}) if isinstance(entry.deltas, dict) else {}
|
||||
|
||||
action = ACTION_CREATE if code in CREATE_CODES else ACTION_UPDATE
|
||||
|
||||
changes = {}
|
||||
|
||||
# Status changes are recorded as field changes
|
||||
if 'status' in deltas or 'status_logical' in deltas:
|
||||
new_status = deltas.get('status')
|
||||
new_logical = deltas.get('status_logical', new_status)
|
||||
old_status = deltas.get('old_status')
|
||||
old_logical = deltas.get('old_status_logical', old_status)
|
||||
|
||||
changes['status'] = [old_logical, new_logical]
|
||||
|
||||
# A custom status is recorded separately to the logical status
|
||||
new_custom = new_status if new_status != new_logical else None
|
||||
old_custom = old_status if old_status != old_logical else None
|
||||
|
||||
if new_custom is not None or old_custom is not None:
|
||||
changes['status_custom_key'] = [old_custom, new_custom]
|
||||
|
||||
for key in STATUS_KEYS:
|
||||
deltas.pop(key, None)
|
||||
|
||||
# A location change (with known previous location) is recorded as a field change
|
||||
if 'old_location' in deltas:
|
||||
old_location = to_pk(deltas.pop('old_location'))
|
||||
new_location = to_pk(deltas.pop('location', None))
|
||||
|
||||
if old_location != new_location:
|
||||
changes['location'] = [old_location, new_location]
|
||||
|
||||
# The initial values of a new stock item are recorded as field changes
|
||||
if action == ACTION_CREATE:
|
||||
for key in CREATE_FIELD_KEYS:
|
||||
if key in deltas:
|
||||
value = deltas.pop(key)
|
||||
|
||||
if key == 'location':
|
||||
value = to_pk(value)
|
||||
elif key == 'quantity':
|
||||
value = format_quantity(value)
|
||||
elif value is not None:
|
||||
value = str(value)
|
||||
|
||||
if value is not None:
|
||||
changes[key] = [None, value]
|
||||
|
||||
# The customer name is recorded as the string representation of the customer reference
|
||||
customer_name = deltas.pop('customer_name', None)
|
||||
|
||||
# Convert references (by pk) to {model_type, pk, str}
|
||||
for key, (app_label, model_name) in REFERENCE_KEYS.items():
|
||||
if key not in deltas:
|
||||
continue
|
||||
|
||||
pk = to_pk(deltas[key])
|
||||
|
||||
if pk is None:
|
||||
continue
|
||||
|
||||
model_type = f'{app_label}.{model_name}'
|
||||
instance = references.get(model_type, {}).get(pk)
|
||||
|
||||
if instance is not None:
|
||||
text = reference_str(model_type, instance)
|
||||
elif key == 'customer' and customer_name:
|
||||
text = customer_name
|
||||
else:
|
||||
# The referenced object no longer exists
|
||||
text = None
|
||||
|
||||
deltas[key] = {'model_type': model_type, 'pk': pk, 'str': text}
|
||||
|
||||
additional_data = {'tracking_id': entry.pk}
|
||||
|
||||
if deltas:
|
||||
additional_data['deltas'] = deltas
|
||||
|
||||
item = entry.item
|
||||
|
||||
if item is not None:
|
||||
object_repr = stock_item_str(item)
|
||||
elif entry.part is not None:
|
||||
object_repr = entry.part.name
|
||||
else:
|
||||
object_repr = ''
|
||||
|
||||
user = entry.user
|
||||
|
||||
return {
|
||||
'object_pk': str(entry.item_id) if entry.item_id is not None else '',
|
||||
'object_id': entry.item_id,
|
||||
'object_repr': object_repr,
|
||||
'action': action,
|
||||
'changes': changes,
|
||||
'changes_text': '',
|
||||
'timestamp': entry.date,
|
||||
'actor_id': entry.user_id,
|
||||
'actor_email': getattr(user, 'email', None) or None,
|
||||
'actor_username': getattr(user, 'username', None),
|
||||
'parent_object_pk': str(entry.part_id) if entry.part_id is not None else None,
|
||||
'event_type': None if code == EDITED else code,
|
||||
'notes': entry.notes[:512] if entry.notes else None,
|
||||
'additional_data': additional_data,
|
||||
}
|
||||
|
||||
|
||||
def collect_references(apps, entries) -> dict:
|
||||
"""Fetch all objects referenced (by pk) in the deltas of the provided entries."""
|
||||
required = {}
|
||||
|
||||
for entry in entries:
|
||||
if not isinstance(entry.deltas, dict):
|
||||
continue
|
||||
|
||||
for key, (app_label, model_name) in REFERENCE_KEYS.items():
|
||||
if (pk := to_pk(entry.deltas.get(key))) is not None:
|
||||
required.setdefault(f'{app_label}.{model_name}', set()).add(pk)
|
||||
|
||||
references = {}
|
||||
|
||||
for model_type, pks in required.items():
|
||||
app_label, model_name = model_type.split('.')
|
||||
model = apps.get_model(app_label, model_name)
|
||||
|
||||
queryset = model.objects.all()
|
||||
|
||||
if model_type == 'stock.stockitem':
|
||||
queryset = queryset.select_related('part')
|
||||
|
||||
references[model_type] = queryset.in_bulk(list(pks))
|
||||
|
||||
return references
|
||||
|
||||
|
||||
def migrate_tracking_entries(apps, schema_editor):
|
||||
"""Copy all StockItemTracking entries into the audit log."""
|
||||
StockItemTracking = apps.get_model('stock', 'stockitemtracking')
|
||||
AuditLogEntry = apps.get_model('audit', 'auditlogentry')
|
||||
ContentType = apps.get_model('contenttypes', 'contenttype')
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
|
||||
if n == 0:
|
||||
return
|
||||
|
||||
print(f'\nstock.migrations.0129: Migrating {n} stock tracking entries to the audit log')
|
||||
|
||||
stock_ct, _ = ContentType.objects.get_or_create(
|
||||
app_label='stock', model='stockitem'
|
||||
)
|
||||
part_ct, _ = ContentType.objects.get_or_create(app_label='part', model='part')
|
||||
|
||||
queryset = StockItemTracking.objects.select_related(
|
||||
'item', 'item__part', 'part', 'user'
|
||||
).order_by('pk')
|
||||
|
||||
last_pk = 0
|
||||
migrated = 0
|
||||
|
||||
while True:
|
||||
entries = list(queryset.filter(pk__gt=last_pk)[:BATCH_SIZE])
|
||||
|
||||
if not entries:
|
||||
break
|
||||
|
||||
references = collect_references(apps, entries)
|
||||
|
||||
audit_entries = []
|
||||
|
||||
for entry in entries:
|
||||
data = convert_entry(entry, references)
|
||||
|
||||
audit_entries.append(
|
||||
AuditLogEntry(
|
||||
content_type_id=stock_ct.pk,
|
||||
parent_content_type_id=part_ct.pk
|
||||
if data['parent_object_pk'] is not None
|
||||
else None,
|
||||
**data,
|
||||
)
|
||||
)
|
||||
|
||||
AuditLogEntry.objects.bulk_create(audit_entries, batch_size=BATCH_SIZE)
|
||||
|
||||
migrated += len(audit_entries)
|
||||
last_pk = entries[-1].pk
|
||||
|
||||
print(f'- Migrated {migrated} / {n} entries')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Copy legacy stock tracking entries into the audit log."""
|
||||
|
||||
dependencies = [
|
||||
('stock', '0128_remove_stockitem_notes'),
|
||||
('audit', '0003_event_type'),
|
||||
('build', '0060_remove_build_notes'),
|
||||
('company', '0083_company_is_internal'),
|
||||
('order', '0124_purchaseorder_delivery_address'),
|
||||
('part', '0155_move_pricing_models'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# The migrated entries are retained on reversal, as the legacy table
|
||||
# (dropped in the following migration) cannot be restored
|
||||
migrations.RunPython(
|
||||
migrate_tracking_entries, reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-25 20:53
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('stock', '0129_migrate_stock_tracking_to_audit_log')]
|
||||
|
||||
operations = [migrations.DeleteModel(name='StockItemTracking')]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,7 @@ import part.models as part_models
|
||||
import part.serializers as part_serializers
|
||||
import stock.filters
|
||||
import stock.status_codes
|
||||
from audit.helpers import audit_event, write_batched
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
@@ -44,14 +45,7 @@ from InvenTree.tasks import batch_offload_tasks
|
||||
from plugin.base.event.events import batch_events
|
||||
from users.serializers import UserSerializer
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
StockLocationType,
|
||||
batch_tracking_entries,
|
||||
)
|
||||
from .models import StockItem, StockItemTestResult, StockLocation, StockLocationType
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
@@ -326,7 +320,7 @@ class StockItemSerializer(
|
||||
- Includes serialization for the item location
|
||||
"""
|
||||
|
||||
export_exclude_fields = ['tags', 'tracking_items']
|
||||
export_exclude_fields = ['tags']
|
||||
|
||||
SKIP_CREATE_FIELDS = ['duplicate']
|
||||
|
||||
@@ -400,7 +394,6 @@ class StockItemSerializer(
|
||||
'supplier_part_detail',
|
||||
'tags',
|
||||
'tests',
|
||||
'tracking_items',
|
||||
]
|
||||
read_only_fields = [
|
||||
'allocated',
|
||||
@@ -486,13 +479,7 @@ class StockItemSerializer(
|
||||
'label': _('Copy Test Results'),
|
||||
'help_text': _('Copy test results from the original stock item'),
|
||||
'default': False,
|
||||
},
|
||||
{
|
||||
'name': 'copy_history',
|
||||
'label': _('Copy History'),
|
||||
'help_text': _('Copy stock history from the original stock item'),
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
@@ -559,7 +546,6 @@ class StockItemSerializer(
|
||||
)
|
||||
|
||||
# Annotate the queryset with the number of tracking items
|
||||
queryset = queryset.annotate(tracking_items=SubqueryCount('tracking_info'))
|
||||
|
||||
# Add flag to indicate if the StockItem has expired
|
||||
queryset = queryset.annotate(
|
||||
@@ -689,9 +675,6 @@ class StockItemSerializer(
|
||||
read_only=True, allow_null=True, label=_('Child Items')
|
||||
)
|
||||
stale = serializers.BooleanField(read_only=True, allow_null=True, label=_('Stale'))
|
||||
tracking_items = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Tracking Items')
|
||||
)
|
||||
|
||||
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Purchase Price'),
|
||||
@@ -773,7 +756,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional note field'),
|
||||
@@ -841,7 +824,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
return (
|
||||
@@ -877,7 +860,7 @@ class InstallStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
label=_('Note'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False,
|
||||
@@ -958,7 +941,7 @@ class UninstallStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False,
|
||||
@@ -1137,7 +1120,7 @@ class DisassembleStockItemSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional note field'),
|
||||
@@ -1305,7 +1288,7 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False,
|
||||
@@ -1325,55 +1308,25 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
||||
|
||||
note = data.get('note', '')
|
||||
|
||||
transaction_notes = []
|
||||
|
||||
now = InvenTree.helpers.current_time()
|
||||
|
||||
# Instead of performing database updates for each item,
|
||||
# perform bulk database updates (much more efficient)
|
||||
|
||||
# Pre-cache the custom status values (to reduce DB hits)
|
||||
custom_status_codes = StockItem.STATUS_CLASS.custom_values()
|
||||
|
||||
for item in items:
|
||||
# Ignore items which are already in the desired status
|
||||
# Audit log entries are written in a single query
|
||||
with write_batched():
|
||||
for item in items:
|
||||
# Ignore items which are already in the desired status
|
||||
|
||||
# Careful check for custom status codes also
|
||||
if item.compare_status(status):
|
||||
custom_status = item.get_custom_status()
|
||||
if status == custom_status or custom_status is None:
|
||||
continue
|
||||
# Careful check for custom status codes also
|
||||
if item.compare_status(status):
|
||||
custom_status = item.get_custom_status()
|
||||
if status == custom_status or custom_status is None:
|
||||
continue
|
||||
|
||||
deltas = {'status': status}
|
||||
item.set_status(status, custom_values=custom_status_codes)
|
||||
|
||||
# before save, track old status logical
|
||||
deltas['old_status_logical'] = item.status
|
||||
|
||||
if item.get_custom_status():
|
||||
deltas['old_status'] = item.get_custom_status()
|
||||
else:
|
||||
deltas['old_status'] = item.status
|
||||
|
||||
item.set_status(status, custom_values=custom_status_codes)
|
||||
item.save(add_note=False)
|
||||
|
||||
# after save, can track new status_logical
|
||||
deltas['status_logical'] = item.status
|
||||
|
||||
# Create a new transaction note for each item
|
||||
transaction_notes.append(
|
||||
StockItemTracking(
|
||||
item=item,
|
||||
tracking_type=stock.status_codes.StockHistoryCode.EDITED.value,
|
||||
date=now,
|
||||
deltas=deltas,
|
||||
user=user,
|
||||
notes=note,
|
||||
)
|
||||
)
|
||||
|
||||
# Create tracking entries
|
||||
StockItemTracking.objects.bulk_create(transaction_notes, batch_size=250)
|
||||
# The status change is recorded as a regular update (with the provided note)
|
||||
with audit_event(item, None, notes=note, actor=user):
|
||||
item.save(add_note=False)
|
||||
|
||||
|
||||
class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
@@ -1538,73 +1491,6 @@ class LocationSerializer(
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class StockTrackingSerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for StockItemTracking model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = StockItemTracking
|
||||
fields = [
|
||||
'pk',
|
||||
'item',
|
||||
'item_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'date',
|
||||
'deltas',
|
||||
'label',
|
||||
'notes',
|
||||
'tracking_type',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
read_only_fields = ['date', 'part', 'user', 'label', 'tracking_type']
|
||||
|
||||
label = serializers.CharField(read_only=True)
|
||||
|
||||
item_detail = OptionalField(
|
||||
serializer_class=StockItemSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['item', 'item__part'],
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=part_serializers.PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=False,
|
||||
prefetch_fields=['part'],
|
||||
)
|
||||
|
||||
user_detail = OptionalField(
|
||||
serializer_class=UserSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'user',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['user'],
|
||||
)
|
||||
|
||||
deltas = serializers.JSONField(read_only=True)
|
||||
|
||||
|
||||
class StockAssignmentItemSerializer(serializers.Serializer):
|
||||
"""Serializer for a single StockItem with in StockAssignment request.
|
||||
|
||||
@@ -1689,7 +1575,7 @@ class StockAssignmentSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Stock assignment notes'),
|
||||
@@ -1779,7 +1665,7 @@ class StockMergeSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Stock merging notes'),
|
||||
@@ -1989,7 +1875,7 @@ class StockAdjustmentSerializer(serializers.Serializer):
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
max_length=StockItemTracking.NOTES_MAX_LENGTH,
|
||||
max_length=StockItem.TRACKING_NOTES_MAX_LENGTH,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Stock transaction notes'),
|
||||
@@ -2073,7 +1959,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
for item in items:
|
||||
@@ -2106,7 +1992,7 @@ class StockAddSerializer(StockAdjustmentSerializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
for item in data['items']:
|
||||
@@ -2140,7 +2026,7 @@ class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
for item in data['items']:
|
||||
@@ -2202,7 +2088,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
with (
|
||||
transaction.atomic(),
|
||||
batch_events(),
|
||||
batch_tracking_entries(),
|
||||
write_batched(),
|
||||
batch_offload_tasks(),
|
||||
):
|
||||
for item in items:
|
||||
@@ -2251,10 +2137,10 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
merge_kwargs['transfer_deltas'] = transfer_deltas
|
||||
target.merge_stock_items([piece], **merge_kwargs)
|
||||
else:
|
||||
transfer_deltas = {'stockitem': stock_item.pk}
|
||||
transfer_deltas = {'stockitem': stock_item}
|
||||
|
||||
if location:
|
||||
transfer_deltas['location'] = location.pk
|
||||
transfer_deltas['location'] = location
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_name in kwargs:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Background tasks for the stock app."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
|
||||
tracer = trace.get_tracer(__name__)
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
@tracer.start_as_current_span('delete_old_stock_tracking')
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def delete_old_stock_tracking():
|
||||
"""Remove old stock tracking entries before a certain date."""
|
||||
from stock.models import StockItemTracking
|
||||
|
||||
if not get_global_setting('STOCK_TRACKING_DELETE_OLD_ENTRIES', False):
|
||||
return
|
||||
|
||||
delete_n_days = int(get_global_setting('STOCK_TRACKING_DELETE_DAYS', 365))
|
||||
|
||||
threshold = datetime.now() - timedelta(days=delete_n_days)
|
||||
|
||||
old_entries = StockItemTracking.objects.filter(date__lte=threshold)
|
||||
|
||||
if old_entries.exists():
|
||||
logger.info(
|
||||
'Deleting old stock tracking entries',
|
||||
count=old_entries.count(),
|
||||
threshold=threshold,
|
||||
)
|
||||
old_entries.delete()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Unit testing for the Stock API."""
|
||||
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -23,12 +23,13 @@ from InvenTree.unit_test import (
|
||||
InvenTreeAPIPerformanceTestCase,
|
||||
InvenTreeAPITestCase,
|
||||
findOffloadedEvent,
|
||||
stock_events,
|
||||
stock_history,
|
||||
)
|
||||
from part.models import Part, PartTestTemplate
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
StockLocationType,
|
||||
)
|
||||
@@ -1621,9 +1622,10 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
# Check that the item was associated with the correct user
|
||||
item = StockItem.objects.get(pk=response.data[0]['pk'])
|
||||
self.assertEqual(item.tracking_info_count, 1)
|
||||
tracking = item.tracking_info.first()
|
||||
self.assertEqual(tracking.user, self.user)
|
||||
self.assertEqual(stock_events(item).count(), 1)
|
||||
tracking = stock_events(item).first()
|
||||
self.assertEqual(tracking.event_type, StockHistoryCode.CREATED)
|
||||
self.assertEqual(tracking.actor, self.user)
|
||||
|
||||
# What if we explicitly set the location to a different value?
|
||||
|
||||
@@ -2110,7 +2112,7 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
def test_note_max_length(self):
|
||||
"""Test that overly long transaction notes are rejected with a validation error."""
|
||||
max_length = StockItemTracking.NOTES_MAX_LENGTH
|
||||
max_length = StockItem.TRACKING_NOTES_MAX_LENGTH
|
||||
note = 'x' * (max_length + 1)
|
||||
|
||||
for url, data in [
|
||||
@@ -2139,7 +2141,7 @@ class StockItemTest(StockAPITestCase):
|
||||
sub_part = part.models.Part.objects.get(pk=50)
|
||||
sub_item = StockItem.objects.create(part=sub_part, serial='xyz-123', quantity=1)
|
||||
|
||||
n_entries = sub_item.tracking_info.count()
|
||||
n_entries = stock_events(sub_item).count()
|
||||
|
||||
self.assertIsNone(sub_item.belongs_to)
|
||||
|
||||
@@ -2170,7 +2172,11 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
self.assertEqual(sub_item.belongs_to, item)
|
||||
|
||||
self.assertEqual(n_entries + 1, sub_item.tracking_info.count())
|
||||
self.assertEqual(n_entries + 1, stock_events(sub_item).count())
|
||||
self.assertEqual(
|
||||
stock_events(sub_item).last().event_type,
|
||||
StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||
)
|
||||
|
||||
# Try to install again - this time, should fail because the StockItem is not available!
|
||||
response = self.post(
|
||||
@@ -2215,7 +2221,7 @@ class StockItemTest(StockAPITestCase):
|
||||
item.customer = customer
|
||||
item.save()
|
||||
|
||||
n_entries = item.tracking_info_count
|
||||
n_entries = stock_events(item).count()
|
||||
|
||||
url = reverse('api-stock-return')
|
||||
|
||||
@@ -2249,8 +2255,12 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
# A new stock tracking entry should have been created
|
||||
self.assertEqual(n_entries + 1, item.tracking_info_count)
|
||||
# A new stock tracking event should have been recorded
|
||||
self.assertEqual(n_entries + 1, stock_events(item).count())
|
||||
|
||||
entry = stock_events(item).last()
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.RETURNED_TO_STOCK)
|
||||
self.assertEqual(entry.notes, 'Returned from this customer for testing')
|
||||
|
||||
# The item is now in stock
|
||||
self.assertIsNone(item.customer)
|
||||
@@ -2300,6 +2310,18 @@ class StockItemTest(StockAPITestCase):
|
||||
stock_item.refresh_from_db()
|
||||
self.assertEqual(stock_item.part, variant)
|
||||
|
||||
def status_change(self, item):
|
||||
"""Return the most recent audit entry for the provided stock item."""
|
||||
from audit.models import AuditLogEntry
|
||||
|
||||
return (
|
||||
AuditLogEntry.objects
|
||||
.get_for_object(item)
|
||||
.filter(action=AuditLogEntry.Action.UPDATE)
|
||||
.order_by('pk')
|
||||
.last()
|
||||
)
|
||||
|
||||
def test_set_status(self):
|
||||
"""Test API endpoint for setting StockItem status."""
|
||||
url = reverse('api-stock-change-status')
|
||||
@@ -2317,11 +2339,12 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.OK.value)
|
||||
self.assertEqual(item.tracking_info.count(), 1)
|
||||
self.assertIsNone(self.status_change(item))
|
||||
|
||||
data = {
|
||||
'items': [item.pk for item in items],
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
'note': 'Damaged in transit',
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
@@ -2330,21 +2353,23 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||
self.assertEqual(item.tracking_info.count(), 2)
|
||||
tracking = item.tracking_info.last()
|
||||
self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value)
|
||||
|
||||
# The status change is recorded as a regular update
|
||||
entry = self.status_change(item)
|
||||
self.assertIsNone(entry.event_type)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(entry.notes, 'Damaged in transit')
|
||||
self.assertEqual(
|
||||
tracking.deltas['old_status_logical'], StockStatus.OK.value
|
||||
)
|
||||
self.assertEqual(tracking.deltas['status'], StockStatus.DAMAGED.value)
|
||||
self.assertEqual(
|
||||
tracking.deltas['status_logical'], StockStatus.DAMAGED.value
|
||||
entry.changes_dict['status'],
|
||||
[StockStatus.OK.value, StockStatus.DAMAGED.value],
|
||||
)
|
||||
|
||||
# Same test, but with one item unchanged
|
||||
items[0].set_status(StockStatus.ATTENTION.value)
|
||||
items[0].save()
|
||||
|
||||
unchanged = self.status_change(items[0])
|
||||
|
||||
data['status'] = StockStatus.ATTENTION.value
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
@@ -2352,10 +2377,15 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.ATTENTION.value)
|
||||
self.assertEqual(item.tracking_info.count(), 3)
|
||||
|
||||
tracking = item.tracking_info.last()
|
||||
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value)
|
||||
# No entry is recorded for the unchanged item
|
||||
self.assertEqual(self.status_change(items[0]).pk, unchanged.pk)
|
||||
|
||||
for item in items[1:]:
|
||||
self.assertEqual(
|
||||
self.status_change(item).changes_dict['status'],
|
||||
[StockStatus.DAMAGED.value, StockStatus.ATTENTION.value],
|
||||
)
|
||||
|
||||
def test_set_custom_status(self):
|
||||
"""Test API endpoint for setting StockItem custom status."""
|
||||
@@ -2374,7 +2404,6 @@ class StockItemTest(StockAPITestCase):
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.OK.value)
|
||||
self.assertEqual(item.tracking_info.count(), 1)
|
||||
|
||||
# Test tracking with custom status
|
||||
# *from* standard *to* custom
|
||||
@@ -2389,35 +2418,36 @@ class StockItemTest(StockAPITestCase):
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, self.inspect_custom_status.logical_key)
|
||||
self.assertEqual(item.get_custom_status(), self.inspect_custom_status.key)
|
||||
tracking = item.tracking_info.last()
|
||||
self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value)
|
||||
|
||||
changes = self.status_change(item).changes_dict
|
||||
|
||||
self.assertEqual(
|
||||
tracking.deltas['old_status_logical'], StockStatus.OK.value
|
||||
changes['status'],
|
||||
[StockStatus.OK.value, self.inspect_custom_status.logical_key],
|
||||
)
|
||||
self.assertEqual(tracking.deltas['status'], self.inspect_custom_status.key)
|
||||
self.assertEqual(
|
||||
tracking.deltas['status_logical'],
|
||||
self.inspect_custom_status.logical_key,
|
||||
changes['status_custom_key'], [None, self.inspect_custom_status.key]
|
||||
)
|
||||
|
||||
# reverse case
|
||||
# *from* custom *to* standard
|
||||
data['status'] = StockStatus.OK.value
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
for item in items:
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, StockStatus.OK.value)
|
||||
self.assertIsNone(item.get_custom_status())
|
||||
tracking = item.tracking_info.last()
|
||||
|
||||
changes = self.status_change(item).changes_dict
|
||||
|
||||
self.assertEqual(
|
||||
tracking.deltas['old_status'], self.inspect_custom_status.key
|
||||
changes['status'],
|
||||
[self.inspect_custom_status.logical_key, StockStatus.OK.value],
|
||||
)
|
||||
self.assertEqual(
|
||||
tracking.deltas['old_status_logical'],
|
||||
self.inspect_custom_status.logical_key,
|
||||
changes['status_custom_key'], [self.inspect_custom_status.key, None]
|
||||
)
|
||||
self.assertEqual(tracking.deltas['status'], StockStatus.OK.value)
|
||||
self.assertEqual(tracking.deltas['status_logical'], StockStatus.OK.value)
|
||||
|
||||
def test_bulk_batch_change(self):
|
||||
"""Test that we can bulk-change batch code for a set of stock items."""
|
||||
@@ -2618,18 +2648,18 @@ class StockItemDisassembleTest(StockAPITestCase):
|
||||
|
||||
# Check stock tracking entries have been created
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.DISASSEMBLED.value
|
||||
).exists()
|
||||
stock_events(self.item, event_type=StockHistoryCode.DISASSEMBLED).exists()
|
||||
)
|
||||
|
||||
for item in [item_1, item_50]:
|
||||
entry = item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
entry = stock_events(
|
||||
item, event_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['stockitem'], self.item.pk)
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['stockitem']['pk'], self.item.pk
|
||||
)
|
||||
|
||||
def test_full_disassembly(self):
|
||||
"""Test that a fully disassembled item is retained at zero quantity."""
|
||||
@@ -2675,13 +2705,13 @@ class StockItemDisassembleTest(StockAPITestCase):
|
||||
# an output of the build) - instead it is recorded in the stock history
|
||||
self.assertIsNone(component.build)
|
||||
|
||||
entry = component.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
entry = stock_events(
|
||||
component, event_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['buildorder'], bo.pk)
|
||||
self.assertEqual(entry.deltas['purchaseorder'], po.pk)
|
||||
self.assertEqual(entry.additional_data['deltas']['buildorder']['pk'], bo.pk)
|
||||
self.assertEqual(entry.additional_data['deltas']['purchaseorder']['pk'], po.pk)
|
||||
|
||||
def test_explicit_pricing(self):
|
||||
"""Test that automatic cost allocation is skipped if explicit pricing is provided."""
|
||||
@@ -2750,23 +2780,23 @@ class StockItemDisassembleTest(StockAPITestCase):
|
||||
|
||||
# Uninstalled items are updated, not created afresh
|
||||
self.assertTrue(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_FROM_ASSEMBLY.value
|
||||
stock_events(
|
||||
sub_1, event_type=StockHistoryCode.REMOVED_FROM_ASSEMBLY
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
stock_events(
|
||||
sub_1, event_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_CHILD_ITEM.value
|
||||
stock_events(
|
||||
self.item, event_type=StockHistoryCode.REMOVED_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
new_item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
stock_events(
|
||||
new_item, event_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -3017,7 +3047,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
|
||||
def test_notes_max_length(self):
|
||||
"""Test that overly long notes are rejected with a validation error."""
|
||||
max_length = StockItemTracking.NOTES_MAX_LENGTH
|
||||
max_length = StockItem.TRACKING_NOTES_MAX_LENGTH
|
||||
|
||||
for endpoint in [
|
||||
'api-stock-count',
|
||||
@@ -3130,12 +3160,9 @@ class StocktakeTest(StockAPITestCase):
|
||||
item_a.refresh_from_db()
|
||||
self.assertEqual(item_a.location.pk, 1)
|
||||
|
||||
# Tracking entry records the location change
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertEqual(entry.deltas.get('location'), 1)
|
||||
self.assertEqual(entry.deltas.get('old_location'), 5)
|
||||
# The stocktake event records the location change
|
||||
entry = stock_events(item_a, event_type=StockHistoryCode.STOCK_COUNT).last()
|
||||
self.assertEqual(entry.changes_dict['location'], [5, 1])
|
||||
|
||||
# --- location is updated for multiple items simultaneously ---
|
||||
response = self.post(
|
||||
@@ -3156,13 +3183,10 @@ class StocktakeTest(StockAPITestCase):
|
||||
self.assertEqual(item_a.location.pk, 2)
|
||||
self.assertEqual(item_b.location.pk, 2)
|
||||
|
||||
# Both items have a tracking entry with the new location
|
||||
# Both items have a stocktake event with the new location
|
||||
for item, old_loc in [(item_a, 1), (item_b, 3)]:
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertEqual(entry.deltas.get('location'), 2)
|
||||
self.assertEqual(entry.deltas.get('old_location'), old_loc)
|
||||
entry = stock_events(item, event_type=StockHistoryCode.STOCK_COUNT).last()
|
||||
self.assertEqual(entry.changes_dict['location'], [old_loc, 2])
|
||||
|
||||
# --- location is unchanged when not provided ---
|
||||
response = self.post(
|
||||
@@ -3173,12 +3197,10 @@ class StocktakeTest(StockAPITestCase):
|
||||
# Location should still be 2 (unchanged from the previous count)
|
||||
self.assertEqual(item_a.location.pk, 2)
|
||||
|
||||
# Tracking entry has no location delta when location was not provided
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertNotIn('location', entry.deltas)
|
||||
self.assertNotIn('old_location', entry.deltas)
|
||||
# No location change is recorded when location was not provided
|
||||
entry = stock_events(item_a, event_type=StockHistoryCode.STOCK_COUNT).last()
|
||||
self.assertNotIn('location', entry.changes_dict)
|
||||
self.assertNotIn('location', (entry.additional_data or {}).get('deltas', {}))
|
||||
|
||||
# --- structural location is rejected ---
|
||||
structural = StockLocation.objects.create(name='Structural', structural=True)
|
||||
@@ -3234,14 +3256,13 @@ class StocktakeTest(StockAPITestCase):
|
||||
self.assertIsNotNone(item.stocktake_date)
|
||||
|
||||
# The stock history entry must agree with the saved state
|
||||
entry = StockItemTracking.objects.filter(
|
||||
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).latest('date')
|
||||
self.assertEqual(entry.deltas.get('quantity'), float(quantity))
|
||||
self.assertEqual(entry.deltas.get('location'), 1)
|
||||
self.assertEqual(entry.deltas.get('old_location'), 5)
|
||||
self.assertEqual(entry.deltas.get('status'), StockStatus.DAMAGED.value)
|
||||
self.assertEqual(entry.deltas.get('old_status'), StockStatus.OK.value)
|
||||
entry = stock_events(item, event_type=StockHistoryCode.STOCK_COUNT).last()
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], float(quantity))
|
||||
self.assertEqual(entry.changes_dict['location'], [5, 1])
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'],
|
||||
[StockStatus.OK.value, StockStatus.DAMAGED.value],
|
||||
)
|
||||
|
||||
def test_bulk_count_query_benchmark(self):
|
||||
"""Benchmark: measure the number of DB queries required to count 100 stock items at once."""
|
||||
@@ -3475,9 +3496,6 @@ class StockTransferMergeTest(StockAPITestCase):
|
||||
|
||||
def test_transfer_merge_does_not_copy_source_tracking(self):
|
||||
"""Transfer merge keeps destination history and adds a single merge entry."""
|
||||
# Track total number of tracking entries created
|
||||
N_TRACKING_ENTRIES = StockItemTracking.objects.count()
|
||||
|
||||
existing = StockItem.objects.create(
|
||||
part=self.part, location=self.dest, quantity=100
|
||||
)
|
||||
@@ -3490,7 +3508,7 @@ class StockTransferMergeTest(StockAPITestCase):
|
||||
)
|
||||
|
||||
incoming_pk = incoming.pk
|
||||
tracking_count = existing.tracking_info.count()
|
||||
tracking_count = stock_events(existing).count()
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
@@ -3503,35 +3521,39 @@ class StockTransferMergeTest(StockAPITestCase):
|
||||
|
||||
existing.refresh_from_db()
|
||||
|
||||
# The history of the incoming item is not copied to the existing item
|
||||
self.assertFalse(
|
||||
existing.tracking_info.filter(notes='Source tracking entry').exists()
|
||||
stock_history(existing, notes='Source tracking entry').exists()
|
||||
)
|
||||
self.assertEqual(existing.tracking_info.count(), tracking_count + 1)
|
||||
merge_entry = existing.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
self.assertEqual(stock_events(existing).count(), tracking_count + 1)
|
||||
|
||||
merge_entry = stock_events(
|
||||
existing, event_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(merge_entry)
|
||||
self.assertEqual(merge_entry.deltas['added'], 50.0)
|
||||
self.assertEqual(merge_entry.deltas['quantity'], 150.0)
|
||||
self.assertEqual(merge_entry.deltas['stockitem'], incoming_pk)
|
||||
self.assertEqual(merge_entry.deltas['location'], self.dest.pk)
|
||||
self.assertEqual(
|
||||
[Decimal(value) for value in merge_entry.changes_dict['quantity']],
|
||||
[100, 150],
|
||||
)
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), N_TRACKING_ENTRIES + 4)
|
||||
deltas = merge_entry.additional_data['deltas']
|
||||
self.assertEqual(deltas['added'], 50.0)
|
||||
self.assertEqual(deltas['stockitem']['pk'], incoming_pk)
|
||||
self.assertEqual(deltas['location']['pk'], self.dest.pk)
|
||||
|
||||
# Ensure tracking entries were bulk created in the correct order
|
||||
entries = list(StockItemTracking.objects.order_by('-pk')[:4])[::-1]
|
||||
# Ensure the expected events were recorded (in order)
|
||||
self.assertEqual(
|
||||
list(stock_events(existing).values_list('event_type', flat=True)),
|
||||
[StockHistoryCode.CREATED, StockHistoryCode.MERGED_STOCK_ITEMS],
|
||||
)
|
||||
self.assertEqual(
|
||||
list(stock_events(incoming_pk).values_list('event_type', flat=True)),
|
||||
[StockHistoryCode.CREATED, StockHistoryCode.STOCK_UPDATE],
|
||||
)
|
||||
|
||||
for idx, tt in enumerate([
|
||||
StockHistoryCode.CREATED,
|
||||
StockHistoryCode.CREATED,
|
||||
StockHistoryCode.STOCK_UPDATE,
|
||||
StockHistoryCode.MERGED_STOCK_ITEMS,
|
||||
]):
|
||||
self.assertEqual(
|
||||
entries[idx].tracking_type,
|
||||
tt,
|
||||
f'Entry {idx} has unexpected tracking type {entries[idx].tracking_type}',
|
||||
)
|
||||
# The incoming item was deleted (as part of the merge)
|
||||
self.assertTrue(stock_history(incoming_pk, action=2).exists())
|
||||
|
||||
def test_transfer_merge_partial_reuses_split_transfer_deltas(self):
|
||||
"""Partial merge reuses split transfer deltas on the merge tracking entry."""
|
||||
@@ -3554,14 +3576,18 @@ class StockTransferMergeTest(StockAPITestCase):
|
||||
incoming.refresh_from_db()
|
||||
self.assertEqual(incoming.quantity, 70)
|
||||
|
||||
merge_entry = existing.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
merge_entry = stock_events(
|
||||
existing, event_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).first()
|
||||
self.assertEqual(merge_entry.deltas['stockitem'], incoming.pk)
|
||||
self.assertEqual(merge_entry.deltas['location'], self.dest.pk)
|
||||
|
||||
deltas = merge_entry.additional_data['deltas']
|
||||
self.assertEqual(deltas['stockitem']['pk'], incoming.pk)
|
||||
self.assertEqual(deltas['location']['pk'], self.dest.pk)
|
||||
|
||||
# No split event is recorded against the source item
|
||||
self.assertFalse(
|
||||
incoming.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
stock_events(
|
||||
incoming, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -4089,127 +4115,6 @@ class StockTestResultTest(StockAPITestCase):
|
||||
)
|
||||
|
||||
|
||||
class StockTrackingTest(StockAPITestCase):
|
||||
"""Tests for the StockTracking API endpoints."""
|
||||
|
||||
fixtures = [
|
||||
*StockAPITestCase.fixtures,
|
||||
'build',
|
||||
'order',
|
||||
'return_order',
|
||||
'sales_order',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Initialize some test data for the StockTracking tests."""
|
||||
super().setUpTestData()
|
||||
|
||||
import build.models
|
||||
import company.models
|
||||
import order.models
|
||||
import stock.models
|
||||
import stock.status_codes
|
||||
|
||||
entries = []
|
||||
|
||||
N_BO = build.models.Build.objects.count()
|
||||
N_PO = order.models.PurchaseOrder.objects.count()
|
||||
N_RO = order.models.ReturnOrder.objects.count()
|
||||
N_SO = order.models.SalesOrder.objects.count()
|
||||
|
||||
N_COMPANY = company.models.Company.objects.count()
|
||||
N_LOCATION = stock.models.StockLocation.objects.count()
|
||||
|
||||
# Generate a large quantity of tracking items
|
||||
# Note that the pk values are not guaranteed to exist in the database
|
||||
for item in StockItem.objects.all():
|
||||
for i in range(50):
|
||||
entries.append(
|
||||
StockItemTracking(
|
||||
item=item,
|
||||
notes='This is a test entry',
|
||||
tracking_type=stock.status_codes.StockHistoryCode.LEGACY.value,
|
||||
deltas={
|
||||
'quantity': 50 - i,
|
||||
'buildorder': random.randint(0, N_BO + 1),
|
||||
'purchaseorder': random.randint(0, N_PO + 1),
|
||||
'returnorder': random.randint(0, N_RO + 1),
|
||||
'salesorder': random.randint(0, N_SO + 1),
|
||||
'customer': random.randint(0, N_COMPANY + 1),
|
||||
'location': random.randint(0, N_LOCATION + 1),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
StockItemTracking.objects.bulk_create(entries)
|
||||
|
||||
def get_url(self):
|
||||
"""Helper function to get stock tracking api url."""
|
||||
return reverse('api-stock-tracking-list')
|
||||
|
||||
def test_count(self):
|
||||
"""Test list endpoint with limit = 1."""
|
||||
url = self.get_url()
|
||||
|
||||
N = StockItemTracking.objects.count()
|
||||
|
||||
# Test counting
|
||||
response = self.get(url, {'limit': 1})
|
||||
self.assertEqual(response.data['count'], N)
|
||||
|
||||
# Test with search and pagination
|
||||
response = self.get(url, {'limit': 1, 'offset': 10, 'search': 'berries'})
|
||||
|
||||
self.assertEqual(response.data['count'], 0)
|
||||
|
||||
def test_list(self):
|
||||
"""Test list endpoint."""
|
||||
url = self.get_url()
|
||||
|
||||
N = StockItemTracking.objects.count()
|
||||
self.assertGreater(N, 1000)
|
||||
|
||||
response = self.client.get(url, max_query_count=25)
|
||||
self.assertEqual(len(response.data), N)
|
||||
|
||||
# Check expected delta values
|
||||
keys = ['quantity', 'returnorder', 'buildorder', 'customer']
|
||||
|
||||
for item in response.data:
|
||||
deltas = item['deltas']
|
||||
|
||||
for key in keys:
|
||||
self.assertIn(key, deltas)
|
||||
self.assertIsNotNone(deltas.get(key, None))
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options."""
|
||||
self.run_output_test(
|
||||
self.get_url(),
|
||||
['item_detail', 'user_detail'],
|
||||
additional_params={'limit': 2},
|
||||
assert_fnc=lambda x: x.data['results'][0],
|
||||
)
|
||||
|
||||
def test_status_codes_endpoint(self):
|
||||
"""The 'track/status/' endpoint must resolve to the status-codes view.
|
||||
|
||||
Regression test: ensures the literal 'status/' path is not shadowed by the
|
||||
'track/<pk>/' detail route it sits alongside in the same urlconf.
|
||||
"""
|
||||
response = self.get(
|
||||
reverse('api-stock-tracking-status-codes'), expected_code=200
|
||||
)
|
||||
|
||||
self.assertIn('status_class', response.data)
|
||||
self.assertIn('values', response.data)
|
||||
self.assertIn('CREATED', response.data['values'])
|
||||
self.assertEqual(
|
||||
response.data['values']['CREATED']['key'], StockHistoryCode.CREATED.value
|
||||
)
|
||||
|
||||
|
||||
class StockAssignTest(StockAPITestCase):
|
||||
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
||||
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Audit log tests for the StockItem model (stock tracking events)."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from audit.models import AuditLogEntry
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
ACTION = AuditLogEntry.Action
|
||||
|
||||
|
||||
class StockAuditTestBase(InvenTreeAPITestCase):
|
||||
"""Base class for stock item audit tests."""
|
||||
|
||||
roles = ['stock.view', 'stock.add', 'stock.change', 'stock.delete', 'part.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Create test data."""
|
||||
super().setUpTestData()
|
||||
|
||||
cls.part = Part.objects.create(
|
||||
name='Audit Widget', description='A widget', component=True
|
||||
)
|
||||
cls.assembly = Part.objects.create(
|
||||
name='Audit Assembly', description='An assembly', assembly=True
|
||||
)
|
||||
cls.trackable = Part.objects.create(
|
||||
name='Audit Trackable', description='A trackable part', trackable=True
|
||||
)
|
||||
|
||||
cls.loc_a = StockLocation.objects.create(name='Location A')
|
||||
cls.loc_b = StockLocation.objects.create(name='Location B')
|
||||
|
||||
def setUp(self):
|
||||
"""Create a stock item for each test."""
|
||||
super().setUp()
|
||||
|
||||
self.item = StockItem.objects.create(
|
||||
part=self.part, quantity=100, location=self.loc_a
|
||||
)
|
||||
|
||||
def entries(self, item):
|
||||
"""Return the audit entries for a stock item (oldest first)."""
|
||||
return AuditLogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(StockItem),
|
||||
object_pk=str(item.pk),
|
||||
).order_by('pk')
|
||||
|
||||
def new_entries(self, item, n):
|
||||
"""Return the audit entries for a stock item, recorded after the first n entries."""
|
||||
return list(self.entries(item)[n:])
|
||||
|
||||
|
||||
class StockAuditTest(StockAuditTestBase):
|
||||
"""Stock operations are recorded as named events in the audit log.
|
||||
|
||||
Each operation records a single audit entry for each affected stock item.
|
||||
"""
|
||||
|
||||
def test_create(self):
|
||||
"""Creating a stock item records a single CREATE entry, with the CREATED event."""
|
||||
[entry] = self.entries(self.item)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.CREATED)
|
||||
self.assertEqual(entry.event_label, 'Stock item created')
|
||||
|
||||
# The entry is linked to the part
|
||||
self.assertEqual(entry.parent_object_pk, str(self.part.pk))
|
||||
|
||||
# Derived fields are not recorded
|
||||
self.assertNotIn('serial_int', entry.changes_dict)
|
||||
self.assertNotIn('barcode_hash', entry.changes_dict)
|
||||
|
||||
def test_create_via_api(self):
|
||||
"""Creating a stock item via the API records the user."""
|
||||
response = self.post(
|
||||
reverse('api-stock-list'),
|
||||
{'part': self.part.pk, 'quantity': 5, 'location': self.loc_a.pk},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
item = StockItem.objects.get(pk=response.data[0]['pk'])
|
||||
|
||||
[entry] = self.entries(item)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.CREATED)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
|
||||
def test_create_serialized_via_api(self):
|
||||
"""Serialized items created via the API are recorded (with status)."""
|
||||
response = self.post(
|
||||
reverse('api-stock-list'),
|
||||
{
|
||||
'part': self.trackable.pk,
|
||||
'quantity': 3,
|
||||
'serial_numbers': '1-3',
|
||||
'location': self.loc_a.pk,
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
for data in response.data:
|
||||
item = StockItem.objects.get(pk=data['pk'])
|
||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# A single entry, which records the creation (including the status)
|
||||
[entry] = self.entries(item)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.CREATED)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(entry.changes_dict['status'][1], StockStatus.DAMAGED.value)
|
||||
|
||||
def test_move(self):
|
||||
"""Moving the entire stock item records a single entry."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.move(self.loc_b, 'Moved', self.user)
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.UPDATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_MOVE)
|
||||
self.assertEqual(entry.notes, 'Moved')
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(entry.changes_dict['location'], [self.loc_a.pk, self.loc_b.pk])
|
||||
|
||||
# The location is recorded as a field change - not duplicated in the deltas
|
||||
self.assertEqual(entry.additional_data['deltas'], {'quantity': 100.0})
|
||||
|
||||
def test_partial_move(self):
|
||||
"""Moving part of a stock item records an entry for each item."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.move(self.loc_b, 'Split', self.user, quantity=30)
|
||||
|
||||
new_item = StockItem.objects.get(parent=self.item)
|
||||
|
||||
# The original item
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.SPLIT_CHILD_ITEM)
|
||||
self.assertEqual(entry.changes_dict['quantity'], ['100.00000', '70.00000'])
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['stockitem'],
|
||||
{'model_type': 'stock.stockitem', 'pk': new_item.pk, 'str': str(new_item)},
|
||||
)
|
||||
|
||||
# The new item
|
||||
[new_entry] = self.entries(new_item)
|
||||
|
||||
self.assertEqual(new_entry.action, ACTION.CREATE)
|
||||
self.assertEqual(new_entry.event_type, StockHistoryCode.SPLIT_FROM_PARENT)
|
||||
self.assertEqual(new_entry.changes_dict['location'][1], self.loc_b.pk)
|
||||
|
||||
def test_add_remove_stock(self):
|
||||
"""Adding / removing stock records a single entry for each operation."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.add_stock(10, self.user, notes='Added', batch='B-123')
|
||||
self.item.take_stock(5, self.user, notes='Removed')
|
||||
|
||||
add, remove = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(add.event_type, StockHistoryCode.STOCK_ADD)
|
||||
self.assertEqual(add.changes_dict['quantity'], ['100.00000', '110.00000'])
|
||||
self.assertEqual(add.changes_dict['batch'], ['', 'B-123'])
|
||||
self.assertEqual(add.additional_data['deltas'], {'added': 10.0})
|
||||
|
||||
self.assertEqual(remove.event_type, StockHistoryCode.STOCK_REMOVE)
|
||||
self.assertEqual(remove.notes, 'Removed')
|
||||
self.assertEqual(remove.additional_data['deltas'], {'removed': 5.0})
|
||||
|
||||
def test_stocktake(self):
|
||||
"""A stocktake is always recorded - even if the quantity is unchanged."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
# Unchanged quantity
|
||||
self.item.stocktake(100, self.user, notes='Counted')
|
||||
|
||||
# Changed quantity
|
||||
self.item.stocktake(90, self.user)
|
||||
|
||||
unchanged, changed = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(unchanged.event_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(unchanged.notes, 'Counted')
|
||||
|
||||
# Nothing has changed - but the event (and counted quantity) is recorded
|
||||
self.assertEqual(unchanged.changes_dict, {})
|
||||
self.assertEqual(unchanged.additional_data['deltas'], {'quantity': 100.0})
|
||||
|
||||
self.assertEqual(changed.event_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(
|
||||
[Decimal(value) for value in changed.changes_dict['quantity']],
|
||||
[Decimal(100), Decimal(90)],
|
||||
)
|
||||
|
||||
def test_deplete(self):
|
||||
"""Depleting a stock item (which is then deleted) records the event against the deletion."""
|
||||
self.item.delete_on_deplete = True
|
||||
self.item.save()
|
||||
|
||||
pk = self.item.pk
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.take_stock(100, self.user)
|
||||
|
||||
self.assertFalse(StockItem.objects.filter(pk=pk).exists())
|
||||
|
||||
[entry] = list(
|
||||
AuditLogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(StockItem),
|
||||
object_pk=str(pk),
|
||||
).order_by('pk')[n:]
|
||||
)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.DELETE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_REMOVE)
|
||||
|
||||
def test_status_change(self):
|
||||
"""Changing the status of stock items (via the API) records a single entry for each item."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.post(
|
||||
reverse('api-stock-change-status'),
|
||||
{
|
||||
'items': [self.item.pk],
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
'note': 'Damaged',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
# A regular update (not a named event) - but the note is retained
|
||||
self.assertEqual(entry.action, ACTION.UPDATE)
|
||||
self.assertIsNone(entry.event_type)
|
||||
self.assertEqual(entry.notes, 'Damaged')
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'],
|
||||
[StockStatus.OK.value, StockStatus.DAMAGED.value],
|
||||
)
|
||||
|
||||
# Status information is recorded as field changes (not as deltas)
|
||||
self.assertIsNone(entry.additional_data)
|
||||
|
||||
def test_edit(self):
|
||||
"""Editing a stock item (e.g. changing the status) records a regular update entry."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.patch(
|
||||
reverse('api-stock-detail', kwargs={'pk': self.item.pk}),
|
||||
{'status': StockStatus.DAMAGED.value, 'batch': 'B-999'},
|
||||
)
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.UPDATE)
|
||||
self.assertIsNone(entry.event_type)
|
||||
self.assertEqual(entry.actor, self.user)
|
||||
self.assertEqual(
|
||||
entry.changes_dict['status'],
|
||||
[StockStatus.OK.value, StockStatus.DAMAGED.value],
|
||||
)
|
||||
self.assertEqual(entry.changes_dict['batch'], ['', 'B-999'])
|
||||
|
||||
def test_install(self):
|
||||
"""Installing an item records an entry against both items."""
|
||||
assembly = StockItem.objects.create(part=self.assembly, quantity=1)
|
||||
|
||||
n_item = self.entries(self.item).count()
|
||||
n_assembly = self.entries(assembly).count()
|
||||
|
||||
assembly.installStockItem(self.item, 100, self.user, 'Installed')
|
||||
|
||||
[item_entry] = self.new_entries(self.item, n_item)
|
||||
[assembly_entry] = self.new_entries(assembly, n_assembly)
|
||||
|
||||
self.assertEqual(
|
||||
item_entry.event_type, StockHistoryCode.INSTALLED_INTO_ASSEMBLY
|
||||
)
|
||||
self.assertEqual(item_entry.changes_dict['belongs_to'], [None, assembly.pk])
|
||||
|
||||
# Nothing has changed for the assembly - the event is still recorded
|
||||
self.assertEqual(
|
||||
assembly_entry.event_type, StockHistoryCode.INSTALLED_CHILD_ITEM
|
||||
)
|
||||
self.assertEqual(assembly_entry.changes_dict, {})
|
||||
self.assertEqual(
|
||||
assembly_entry.additional_data['deltas']['stockitem']['pk'], self.item.pk
|
||||
)
|
||||
|
||||
# Uninstall
|
||||
self.item.refresh_from_db()
|
||||
|
||||
n_item = self.entries(self.item).count()
|
||||
n_assembly = self.entries(assembly).count()
|
||||
|
||||
self.item.uninstall_into_location(self.loc_b, self.user, 'Removed')
|
||||
|
||||
[item_entry] = self.new_entries(self.item, n_item)
|
||||
[assembly_entry] = self.new_entries(assembly, n_assembly)
|
||||
|
||||
self.assertEqual(item_entry.event_type, StockHistoryCode.REMOVED_FROM_ASSEMBLY)
|
||||
self.assertEqual(assembly_entry.event_type, StockHistoryCode.REMOVED_CHILD_ITEM)
|
||||
|
||||
def test_serialize(self):
|
||||
"""Serializing stock records an entry for each new item, and the original item."""
|
||||
item = StockItem.objects.create(part=self.trackable, quantity=5)
|
||||
|
||||
n = self.entries(item).count()
|
||||
|
||||
item.serializeStock(3, ['A1', 'A2', 'A3'], self.user, notes='Serialized')
|
||||
|
||||
[entry] = self.new_entries(item, n)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_SERIALIZED)
|
||||
|
||||
for child in StockItem.objects.filter(parent=item):
|
||||
[child_entry] = self.entries(child)
|
||||
|
||||
self.assertEqual(child_entry.action, ACTION.CREATE)
|
||||
self.assertEqual(child_entry.event_type, StockHistoryCode.SPLIT_FROM_PARENT)
|
||||
self.assertEqual(child_entry.actor, self.user)
|
||||
self.assertEqual(child_entry.changes_dict['serial'][1], child.serial)
|
||||
|
||||
def test_merge(self):
|
||||
"""Merging stock items records the event against the merged item."""
|
||||
other = StockItem.objects.create(
|
||||
part=self.part, quantity=50, location=self.loc_a
|
||||
)
|
||||
other_pk = other.pk
|
||||
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.merge_stock_items([other], user=self.user, location=self.loc_a)
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.MERGED_STOCK_ITEMS)
|
||||
self.assertEqual(entry.changes_dict['quantity'], ['100.00000', '150.00000'])
|
||||
|
||||
# The other item was deleted
|
||||
self.assertTrue(
|
||||
AuditLogEntry.objects.filter(
|
||||
object_pk=str(other_pk), action=ACTION.DELETE
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_part_history(self):
|
||||
"""Stock item entries are included in the history of the part."""
|
||||
self.item.move(self.loc_b, '', self.user)
|
||||
|
||||
data = self.get(
|
||||
reverse('api-audit-log-list'),
|
||||
{'model_type': 'part.part', 'model_id': self.part.pk},
|
||||
).data
|
||||
|
||||
stock_entries = [e for e in data if e['model_type'] == 'stock.stockitem']
|
||||
|
||||
self.assertEqual(len(stock_entries), 2)
|
||||
self.assertEqual(stock_entries[0]['event_label'], 'Location changed')
|
||||
|
||||
def test_permissions(self):
|
||||
"""Stock history is visible to (non-staff) users with permission to view stock."""
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
url = reverse('api-audit-log-list')
|
||||
params = {'model_type': 'stock.stockitem', 'model_id': self.item.pk}
|
||||
|
||||
self.assertEqual(len(self.get(url, params).data), 1)
|
||||
|
||||
self.clearRoles()
|
||||
self.assignRole('part.view')
|
||||
|
||||
self.assertEqual(self.get(url, params).data, [])
|
||||
|
||||
|
||||
class StockEventCoverageTest(StockAuditTestBase):
|
||||
"""Regression tests for the remaining stock tracking event types."""
|
||||
|
||||
def latest(self, item):
|
||||
"""Return the most recent audit entry for a stock item."""
|
||||
return self.entries(item).last()
|
||||
|
||||
def test_update_same_location(self):
|
||||
"""Moving stock into its current location records a STOCK_UPDATE event."""
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.move(self.loc_a, 'Same place', self.user, batch='B-2')
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_UPDATE)
|
||||
self.assertEqual(entry.notes, 'Same place')
|
||||
self.assertEqual(entry.changes_dict['batch'], ['', 'B-2'])
|
||||
|
||||
def test_customer(self):
|
||||
"""Sending stock to a customer (and returning it) records the expected events."""
|
||||
from company.models import Company
|
||||
|
||||
customer = Company.objects.create(name='Audit Customer', is_customer=True)
|
||||
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.allocateToCustomer(customer, user=self.user, notes='Sent')
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.SENT_TO_CUSTOMER)
|
||||
self.assertEqual(entry.changes_dict['customer'], [None, customer.pk])
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], 100.0)
|
||||
|
||||
# Return the item from the customer
|
||||
self.item.refresh_from_db()
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.return_from_customer(self.loc_b, user=self.user, notes='Returned')
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.RETURNED_FROM_CUSTOMER)
|
||||
self.assertEqual(entry.notes, 'Returned')
|
||||
|
||||
# The customer is recorded as a field change (not duplicated in the deltas)
|
||||
self.assertEqual(entry.changes_dict['customer'], [customer.pk, None])
|
||||
self.assertNotIn('customer', entry.additional_data['deltas'])
|
||||
|
||||
def test_ship_against_order(self):
|
||||
"""Allocating stock against a sales order records the SHIPPED_AGAINST_SALES_ORDER event."""
|
||||
from company.models import Company
|
||||
from order.models import SalesOrder
|
||||
|
||||
customer = Company.objects.create(name='Order Customer', is_customer=True)
|
||||
order = SalesOrder.objects.create(customer=customer, reference='SO-7777')
|
||||
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.allocateToCustomer(customer, quantity=40, order=order, user=self.user)
|
||||
|
||||
# The original item is split - the remaining stock records the split
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.SPLIT_CHILD_ITEM)
|
||||
|
||||
shipped = StockItem.objects.get(parent=self.item)
|
||||
|
||||
# The split item records its creation, and the shipment
|
||||
[created, shipped_entry] = self.entries(shipped)
|
||||
|
||||
self.assertEqual(created.event_type, StockHistoryCode.SPLIT_FROM_PARENT)
|
||||
self.assertEqual(
|
||||
shipped_entry.event_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
|
||||
)
|
||||
self.assertEqual(
|
||||
shipped_entry.additional_data['deltas']['salesorder']['pk'], order.pk
|
||||
)
|
||||
|
||||
def test_return_to_stock(self):
|
||||
"""Returning (consumed) stock to stock records the RETURNED_TO_STOCK event."""
|
||||
from company.models import Company
|
||||
|
||||
customer = Company.objects.create(name='Return Customer', is_customer=True)
|
||||
|
||||
self.item.customer = customer
|
||||
self.item.location = None
|
||||
self.item.save()
|
||||
|
||||
n = self.entries(self.item).count()
|
||||
|
||||
self.item.return_to_stock(self.loc_b, self.user, notes='Back in stock')
|
||||
|
||||
[entry] = self.new_entries(self.item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.RETURNED_TO_STOCK)
|
||||
self.assertEqual(entry.notes, 'Back in stock')
|
||||
self.assertEqual(entry.changes_dict['location'], [None, self.loc_b.pk])
|
||||
|
||||
def test_convert_to_variant(self):
|
||||
"""Converting a stock item to a variant records the CONVERTED_TO_VARIANT event."""
|
||||
template = Part.objects.create(
|
||||
name='Template', description='A template', is_template=True
|
||||
)
|
||||
variant = Part.objects.create(
|
||||
name='Variant', description='A variant', variant_of=template
|
||||
)
|
||||
|
||||
item = StockItem.objects.create(part=template, quantity=5)
|
||||
n = self.entries(item).count()
|
||||
|
||||
item.convert_to_variant(variant, self.user)
|
||||
|
||||
[entry] = self.new_entries(item, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.CONVERTED_TO_VARIANT)
|
||||
self.assertEqual(entry.changes_dict['part'], [template.pk, variant.pk])
|
||||
|
||||
def test_disassemble(self):
|
||||
"""Disassembling stock records DISASSEMBLED and CREATED_FROM_DISASSEMBLY events."""
|
||||
from part.models import BomItem
|
||||
|
||||
bom_item = BomItem.objects.create(
|
||||
part=self.assembly, sub_part=self.part, quantity=2
|
||||
)
|
||||
|
||||
assembly = StockItem.objects.create(part=self.assembly, quantity=5)
|
||||
n = self.entries(assembly).count()
|
||||
|
||||
components = assembly.disassemble(
|
||||
2,
|
||||
[{'bom_item': bom_item, 'quantity': 4, 'location': self.loc_b}],
|
||||
self.user,
|
||||
notes='Broken down',
|
||||
)
|
||||
|
||||
[entry] = self.new_entries(assembly, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.DISASSEMBLED)
|
||||
self.assertEqual(entry.additional_data['deltas'], {'removed': 2.0})
|
||||
|
||||
[component] = components
|
||||
[entry] = self.entries(component)
|
||||
|
||||
self.assertEqual(entry.action, ACTION.CREATE)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.CREATED_FROM_DISASSEMBLY)
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['stockitem']['pk'], assembly.pk
|
||||
)
|
||||
|
||||
def test_transfer_merge(self):
|
||||
"""Transferring stock into an existing item (merge) records the event against the merged item."""
|
||||
target = StockItem.objects.create(
|
||||
part=self.part, quantity=10, location=self.loc_b
|
||||
)
|
||||
|
||||
n = self.entries(target).count()
|
||||
|
||||
self.post(
|
||||
reverse('api-stock-transfer'),
|
||||
{
|
||||
'items': [{'pk': self.item.pk, 'quantity': 100, 'merge': True}],
|
||||
'location': self.loc_b.pk,
|
||||
'notes': 'Merged on transfer',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
target.refresh_from_db()
|
||||
self.assertEqual(target.quantity, 110)
|
||||
|
||||
[entry] = self.new_entries(target, n)
|
||||
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.MERGED_STOCK_ITEMS)
|
||||
self.assertEqual(entry.notes, 'Merged on transfer')
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas']['stockitem']['pk'], self.item.pk
|
||||
)
|
||||
@@ -272,3 +272,364 @@ class TestRemoveMpttFieldsMigration(MigratorTestCase):
|
||||
# The orphan item still has no parent
|
||||
orphan = StockItem.objects.get(pk=self.orphan_pk)
|
||||
self.assertIsNone(orphan.parent_id)
|
||||
|
||||
|
||||
class TestStockTrackingAuditMigration(MigratorTestCase):
|
||||
"""Test data migration which copies StockItemTracking entries into the audit log.
|
||||
|
||||
Ensures that every tracking entry is copied exactly once, with the event type,
|
||||
user, date, notes, parent (part) reference and deltas transferred correctly.
|
||||
"""
|
||||
|
||||
migrate_from = ('stock', '0128_remove_stockitem_notes')
|
||||
migrate_to = ('stock', '0129_migrate_stock_tracking_to_audit_log')
|
||||
|
||||
def make_item(self, part, quantity, serial=None, location=None) -> int:
|
||||
"""Insert a StockItem via raw SQL (avoids a duplicate status_custom_key ORM bug)."""
|
||||
from django.db import connection
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO stock_stockitem
|
||||
(part_id, quantity, status, delete_on_deplete, is_building,
|
||||
link, serial_int, barcode_data, barcode_hash, serial, location_id)
|
||||
VALUES (%s, %s, 10, false, false, '', 0, '', '', %s, %s)
|
||||
"""
|
||||
|
||||
params = [part.pk, quantity, serial, location.pk if location else None]
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if connection.features.can_return_rows_from_bulk_insert:
|
||||
cursor.execute(insert_sql + 'RETURNING id', params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(insert_sql, params)
|
||||
return cursor.lastrowid
|
||||
|
||||
def prepare(self):
|
||||
"""Create legacy stock tracking entries to be migrated."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
apps = self.old_state.apps
|
||||
|
||||
User = apps.get_model('auth', 'user')
|
||||
Part = apps.get_model('part', 'part')
|
||||
Company = apps.get_model('company', 'company')
|
||||
SalesOrder = apps.get_model('order', 'salesorder')
|
||||
StockLocation = apps.get_model('stock', 'stocklocation')
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
StockItemTracking = apps.get_model('stock', 'stockitemtracking')
|
||||
|
||||
self.user = User.objects.create(username='tracker', email='tracker@example.com')
|
||||
|
||||
self.part = Part.objects.create(
|
||||
name='Tracked Widget', level=0, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
|
||||
self.loc_1 = StockLocation.objects.create(
|
||||
name='Shelf', pathstring='Store/Shelf', level=0, tree_id=1, lft=1, rght=2
|
||||
)
|
||||
self.loc_2 = StockLocation.objects.create(
|
||||
name='Bin', pathstring='Store/Bin', level=0, tree_id=2, lft=1, rght=2
|
||||
)
|
||||
|
||||
self.customer = Company.objects.create(name='Acme Customer', is_customer=True)
|
||||
self.sales_order = SalesOrder.objects.create(
|
||||
reference='SO-1234', customer=self.customer, reference_int=1234
|
||||
)
|
||||
|
||||
item_pk = self.make_item(self.part, 100, location=self.loc_1)
|
||||
other_pk = self.make_item(self.part, 1, serial='SN-42')
|
||||
|
||||
item = StockItem.objects.get(pk=item_pk)
|
||||
other = StockItem.objects.get(pk=other_pk)
|
||||
|
||||
self.item_pk = item_pk
|
||||
self.other_pk = other_pk
|
||||
|
||||
def track(code, deltas=None, notes='', user=None, stock_item=item, day=1):
|
||||
"""Create a legacy tracking entry (with a known date)."""
|
||||
entry = StockItemTracking.objects.create(
|
||||
item=stock_item,
|
||||
part=self.part,
|
||||
tracking_type=code,
|
||||
deltas=deltas,
|
||||
notes=notes,
|
||||
user=user,
|
||||
)
|
||||
|
||||
# MySQL rejects timezone-aware values when USE_TZ is False
|
||||
utc = timezone.utc if settings.USE_TZ else None
|
||||
date = datetime(2025, 1, day, 12, 0, tzinfo=utc)
|
||||
StockItemTracking.objects.filter(pk=entry.pk).update(date=date)
|
||||
|
||||
return entry.pk
|
||||
|
||||
self.entries = {
|
||||
'created': track(
|
||||
1,
|
||||
{'status': 10, 'quantity': 100.0, 'location': self.loc_1.pk},
|
||||
notes='Initial stock',
|
||||
user=self.user,
|
||||
day=1,
|
||||
),
|
||||
'moved': track(
|
||||
20,
|
||||
{'quantity': 100.0, 'location': self.loc_2.pk},
|
||||
user=self.user,
|
||||
day=2,
|
||||
),
|
||||
'counted': track(
|
||||
10,
|
||||
{
|
||||
'quantity': 90.0,
|
||||
'location': self.loc_1.pk,
|
||||
'old_location': self.loc_2.pk,
|
||||
},
|
||||
user=self.user,
|
||||
day=3,
|
||||
),
|
||||
'edited': track(
|
||||
5,
|
||||
{
|
||||
'status': 55,
|
||||
'status_logical': 55,
|
||||
'old_status': 10,
|
||||
'old_status_logical': 10,
|
||||
},
|
||||
user=self.user,
|
||||
day=4,
|
||||
),
|
||||
'custom_status': track(
|
||||
5,
|
||||
{
|
||||
'status': 1001,
|
||||
'status_logical': 50,
|
||||
'old_status': 55,
|
||||
'old_status_logical': 55,
|
||||
},
|
||||
day=5,
|
||||
),
|
||||
'shipped': track(
|
||||
60,
|
||||
{
|
||||
'quantity': 5.0,
|
||||
'salesorder': self.sales_order.pk,
|
||||
'customer': self.customer.pk,
|
||||
'customer_name': 'Acme Customer',
|
||||
},
|
||||
user=self.user,
|
||||
day=6,
|
||||
),
|
||||
'split': track(42, {'stockitem': other_pk, 'removed': 5.0}, day=7),
|
||||
'missing_reference': track(
|
||||
42, {'stockitem': 999999, 'removed': 1.0}, day=8
|
||||
),
|
||||
'deleted_item': track(12, {'removed': 1.0}, stock_item=None, day=9),
|
||||
'legacy': track(0, None, notes='Legacy entry', stock_item=other, day=10),
|
||||
}
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), len(self.entries))
|
||||
|
||||
def migrated(self, name):
|
||||
"""Return the audit log entry migrated from the named tracking entry."""
|
||||
AuditLogEntry = self.new_state.apps.get_model('audit', 'auditlogentry')
|
||||
|
||||
return AuditLogEntry.objects.get(
|
||||
additional_data__tracking_id=self.entries[name]
|
||||
)
|
||||
|
||||
def check_integrity(self):
|
||||
"""Every tracking entry is copied exactly once, with all common fields transferred."""
|
||||
apps = self.new_state.apps
|
||||
|
||||
AuditLogEntry = apps.get_model('audit', 'auditlogentry')
|
||||
StockItemTracking = apps.get_model('stock', 'stockitemtracking')
|
||||
ContentType = apps.get_model('contenttypes', 'contenttype')
|
||||
|
||||
stock_ct = ContentType.objects.get(app_label='stock', model='stockitem')
|
||||
part_ct = ContentType.objects.get(app_label='part', model='part')
|
||||
|
||||
# The legacy entries are retained
|
||||
self.assertEqual(StockItemTracking.objects.count(), len(self.entries))
|
||||
|
||||
migrated = AuditLogEntry.objects.filter(
|
||||
content_type=stock_ct, additional_data__has_key='tracking_id'
|
||||
)
|
||||
|
||||
self.assertEqual(migrated.count(), len(self.entries))
|
||||
self.assertEqual(
|
||||
sorted(entry.additional_data['tracking_id'] for entry in migrated),
|
||||
sorted(self.entries.values()),
|
||||
)
|
||||
|
||||
for tracking in StockItemTracking.objects.all():
|
||||
entry = migrated.get(additional_data__tracking_id=tracking.pk)
|
||||
|
||||
self.assertEqual(entry.timestamp, tracking.date)
|
||||
self.assertEqual(entry.actor_id, tracking.user_id)
|
||||
self.assertEqual(entry.notes, tracking.notes or None)
|
||||
|
||||
# The entry is recorded against the stock item
|
||||
self.assertEqual(entry.content_type_id, stock_ct.pk)
|
||||
self.assertEqual(entry.object_id, tracking.item_id)
|
||||
self.assertEqual(
|
||||
entry.object_pk, str(tracking.item_id) if tracking.item_id else ''
|
||||
)
|
||||
|
||||
# The parent reference is the part
|
||||
self.assertEqual(entry.parent_content_type_id, part_ct.pk)
|
||||
self.assertEqual(entry.parent_object_pk, str(self.part.pk))
|
||||
|
||||
# The event type is transferred ('EDITED' entries are regular updates)
|
||||
if tracking.tracking_type == 5:
|
||||
self.assertIsNone(entry.event_type)
|
||||
else:
|
||||
self.assertEqual(entry.event_type, tracking.tracking_type)
|
||||
|
||||
def check_created(self):
|
||||
"""A creation entry is recorded as a CREATE entry, with the initial values as field changes."""
|
||||
entry = self.migrated('created')
|
||||
|
||||
self.assertEqual(entry.action, 0)
|
||||
self.assertEqual(entry.event_type, 1)
|
||||
self.assertEqual(entry.notes, 'Initial stock')
|
||||
self.assertEqual(entry.actor_id, self.user.pk)
|
||||
self.assertEqual(entry.actor_username, 'tracker')
|
||||
self.assertEqual(entry.actor_email, 'tracker@example.com')
|
||||
self.assertEqual(entry.object_repr, '100 x Tracked Widget')
|
||||
|
||||
self.assertEqual(
|
||||
entry.changes,
|
||||
{
|
||||
'status': [None, 10],
|
||||
'quantity': [None, '100'],
|
||||
'location': [None, self.loc_1.pk],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertNotIn('deltas', entry.additional_data)
|
||||
|
||||
def check_moved(self):
|
||||
"""A move records the destination location as a reference."""
|
||||
entry = self.migrated('moved')
|
||||
|
||||
self.assertEqual(entry.action, 1)
|
||||
self.assertEqual(entry.event_type, 20)
|
||||
self.assertEqual(entry.changes, {})
|
||||
self.assertEqual(
|
||||
entry.additional_data['deltas'],
|
||||
{
|
||||
'quantity': 100.0,
|
||||
'location': {
|
||||
'model_type': 'stock.stocklocation',
|
||||
'pk': self.loc_2.pk,
|
||||
'str': 'Store/Bin',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def check_counted(self):
|
||||
"""A location change (with known previous location) is recorded as a field change."""
|
||||
entry = self.migrated('counted')
|
||||
|
||||
self.assertEqual(entry.event_type, 10)
|
||||
self.assertEqual(entry.changes, {'location': [self.loc_2.pk, self.loc_1.pk]})
|
||||
self.assertEqual(entry.additional_data['deltas'], {'quantity': 90.0})
|
||||
|
||||
def check_status_changes(self):
|
||||
"""Status changes are recorded as field changes (including custom status values)."""
|
||||
entry = self.migrated('edited')
|
||||
|
||||
self.assertEqual(entry.action, 1)
|
||||
self.assertIsNone(entry.event_type)
|
||||
self.assertEqual(entry.changes, {'status': [10, 55]})
|
||||
self.assertNotIn('deltas', entry.additional_data)
|
||||
|
||||
entry = self.migrated('custom_status')
|
||||
|
||||
self.assertEqual(
|
||||
entry.changes, {'status': [55, 50], 'status_custom_key': [None, 1001]}
|
||||
)
|
||||
|
||||
def check_references(self):
|
||||
"""References to other objects are converted to {model_type, pk, str}."""
|
||||
deltas = self.migrated('shipped').additional_data['deltas']
|
||||
|
||||
self.assertEqual(
|
||||
deltas,
|
||||
{
|
||||
'quantity': 5.0,
|
||||
'salesorder': {
|
||||
'model_type': 'order.salesorder',
|
||||
'pk': self.sales_order.pk,
|
||||
'str': 'SO-1234',
|
||||
},
|
||||
'customer': {
|
||||
'model_type': 'company.company',
|
||||
'pk': self.customer.pk,
|
||||
'str': 'Acme Customer',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
deltas = self.migrated('split').additional_data['deltas']
|
||||
|
||||
self.assertEqual(
|
||||
deltas['stockitem'],
|
||||
{
|
||||
'model_type': 'stock.stockitem',
|
||||
'pk': self.other_pk,
|
||||
'str': 'Tracked Widget #SN-42',
|
||||
},
|
||||
)
|
||||
self.assertEqual(deltas['removed'], 5.0)
|
||||
|
||||
# A reference to an object which no longer exists
|
||||
deltas = self.migrated('missing_reference').additional_data['deltas']
|
||||
|
||||
self.assertEqual(
|
||||
deltas['stockitem'],
|
||||
{'model_type': 'stock.stockitem', 'pk': 999999, 'str': None},
|
||||
)
|
||||
|
||||
def check_deleted_item(self):
|
||||
"""Entries for deleted stock items are retained against the part."""
|
||||
entry = self.migrated('deleted_item')
|
||||
|
||||
self.assertEqual(entry.object_pk, '')
|
||||
self.assertIsNone(entry.object_id)
|
||||
self.assertEqual(entry.object_repr, 'Tracked Widget')
|
||||
self.assertEqual(entry.parent_object_pk, str(self.part.pk))
|
||||
self.assertEqual(entry.event_type, 12)
|
||||
|
||||
def check_legacy(self):
|
||||
"""Legacy entries (without user or deltas) are migrated."""
|
||||
entry = self.migrated('legacy')
|
||||
|
||||
self.assertEqual(entry.event_type, 0)
|
||||
self.assertIsNone(entry.actor_id)
|
||||
self.assertIsNone(entry.actor_username)
|
||||
self.assertEqual(entry.notes, 'Legacy entry')
|
||||
self.assertEqual(entry.object_repr, 'Tracked Widget #SN-42')
|
||||
self.assertEqual(entry.changes, {})
|
||||
self.assertEqual(entry.additional_data, {'tracking_id': self.entries['legacy']})
|
||||
|
||||
def test_migration(self):
|
||||
"""Run all checks against the migrated data.
|
||||
|
||||
Note: A single test method is used, as the migrations are run for each test method.
|
||||
"""
|
||||
for check in [
|
||||
self.check_integrity,
|
||||
self.check_created,
|
||||
self.check_moved,
|
||||
self.check_counted,
|
||||
self.check_status_changes,
|
||||
self.check_references,
|
||||
self.check_deleted_item,
|
||||
self.check_legacy,
|
||||
]:
|
||||
with self.subTest(check=check.__name__):
|
||||
check()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import threading
|
||||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -15,24 +16,24 @@ from django_q.models import OrmQ
|
||||
from djmoney.money import Money
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from audit.helpers import write_batched
|
||||
from audit.models import AuditLogEntry
|
||||
from build.models import Build
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import Company
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeTestCase
|
||||
from InvenTree.unit_test import (
|
||||
AdminTestCase,
|
||||
InvenTreeTestCase,
|
||||
stock_events,
|
||||
stock_history,
|
||||
)
|
||||
from order.models import SalesOrder
|
||||
from part.models import Part, PartTestTemplate
|
||||
from plugin.base.event.events import batch_events
|
||||
from stock.events import StockEvents
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
StockLocationType,
|
||||
batch_tracking_entries,
|
||||
)
|
||||
from .models import StockItem, StockItemTestResult, StockLocation, StockLocationType
|
||||
|
||||
|
||||
class StockTestBase(InvenTreeTestCase):
|
||||
@@ -295,21 +296,23 @@ class StockTest(StockTestBase):
|
||||
)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
track = stock_events(it).last()
|
||||
|
||||
self.assertEqual(track.item, it)
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_MOVE)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.STOCK_MOVE)
|
||||
self.assertEqual(track.notes, 'Moved to the bathroom')
|
||||
|
||||
def test_self_move(self):
|
||||
"""Test moving stock to its current location."""
|
||||
it = StockItem.objects.get(pk=1)
|
||||
|
||||
n = it.tracking_info.count()
|
||||
n = stock_events(it).count()
|
||||
self.assertTrue(it.move(it.location, 'Moved to same place', None))
|
||||
|
||||
# Ensure tracking info was not added
|
||||
self.assertEqual(it.tracking_info.count(), n + 1)
|
||||
# A STOCK_UPDATE event is recorded
|
||||
self.assertEqual(stock_events(it).count(), n + 1)
|
||||
self.assertEqual(
|
||||
stock_events(it).last().event_type, StockHistoryCode.STOCK_UPDATE
|
||||
)
|
||||
|
||||
def test_partial_move(self):
|
||||
"""Test partial stock moving."""
|
||||
@@ -361,12 +364,14 @@ class StockTest(StockTestBase):
|
||||
parent = StockItem.objects.get(id=1234)
|
||||
child = parent.splitStock(100, None, self.user)
|
||||
|
||||
parent_entry = parent.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.SPLIT_CHILD_ITEM.value
|
||||
parent_entry = stock_events(
|
||||
parent, event_type=StockHistoryCode.SPLIT_CHILD_ITEM
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(parent_entry)
|
||||
self.assertEqual(parent_entry.deltas.get('stockitem'), child.pk)
|
||||
self.assertEqual(
|
||||
parent_entry.additional_data['deltas']['stockitem']['pk'], child.pk
|
||||
)
|
||||
|
||||
def test_delete_reparents_children(self):
|
||||
"""Test that deleting an intermediate item re-links children to the grandparent."""
|
||||
@@ -456,14 +461,16 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(item.quantity, 0)
|
||||
|
||||
# History records the quantity which was *actually* removed
|
||||
entry = item.tracking_info.latest('pk')
|
||||
self.assertEqual(entry.deltas['removed'], 10.0)
|
||||
self.assertEqual(entry.deltas['quantity'], 0.0)
|
||||
entry = stock_events(item).last()
|
||||
self.assertEqual(entry.additional_data['deltas']['removed'], 10.0)
|
||||
self.assertEqual(
|
||||
[Decimal(value) for value in entry.changes_dict['quantity']], [10, 0]
|
||||
)
|
||||
|
||||
# Removing stock from an empty item fails, and adds no history
|
||||
n = item.tracking_info.count()
|
||||
n = stock_history(item).count()
|
||||
self.assertFalse(item.take_stock(5, self.user))
|
||||
self.assertEqual(item.tracking_info.count(), n)
|
||||
self.assertEqual(stock_history(item).count(), n)
|
||||
|
||||
# --- allocateToCustomer: allocate more than available ---
|
||||
customer = Company.objects.create(name='Clamp customer', is_customer=True)
|
||||
@@ -477,8 +484,8 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(allocated.customer, customer)
|
||||
|
||||
# History records the quantity which was *actually* allocated
|
||||
entry = allocated.tracking_info.latest('pk')
|
||||
self.assertEqual(entry.deltas['quantity'], 10.0)
|
||||
entry = stock_events(allocated).last()
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], 10.0)
|
||||
|
||||
# A zero quantity is rejected outright
|
||||
item = StockItem.objects.create(part=part, quantity=10)
|
||||
@@ -503,15 +510,15 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(component.quantity, 10)
|
||||
|
||||
# Both history entries record the quantity which was *actually* installed
|
||||
entry = component.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY
|
||||
).latest('pk')
|
||||
self.assertEqual(entry.deltas['quantity'], 10.0)
|
||||
entry = stock_events(
|
||||
component, event_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY
|
||||
).last()
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], 10.0)
|
||||
|
||||
entry = parent_item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.INSTALLED_CHILD_ITEM
|
||||
).latest('pk')
|
||||
self.assertEqual(entry.deltas['quantity'], 10.0)
|
||||
entry = stock_events(
|
||||
parent_item, event_type=StockHistoryCode.INSTALLED_CHILD_ITEM
|
||||
).last()
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], 10.0)
|
||||
|
||||
# A zero quantity is rejected outright
|
||||
other = StockItem.objects.create(part=part, quantity=5)
|
||||
@@ -527,8 +534,8 @@ class StockTest(StockTestBase):
|
||||
item.belongs_to = parent
|
||||
item.save()
|
||||
|
||||
n_entries = item.tracking_info.count()
|
||||
n_parent_entries = parent.tracking_info.count()
|
||||
n_entries = stock_history(item).count()
|
||||
n_parent_entries = stock_history(parent).count()
|
||||
|
||||
structural = StockLocation.objects.create(
|
||||
name='Structural location', structural=True
|
||||
@@ -541,8 +548,8 @@ class StockTest(StockTestBase):
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.belongs_to, parent)
|
||||
self.assertNotEqual(item.location, structural)
|
||||
self.assertEqual(item.tracking_info.count(), n_entries)
|
||||
self.assertEqual(parent.tracking_info.count(), n_parent_entries)
|
||||
self.assertEqual(stock_history(item).count(), n_entries)
|
||||
self.assertEqual(stock_history(parent).count(), n_parent_entries)
|
||||
|
||||
# Uninstalling into a non-structural location is still permitted
|
||||
item.uninstall_into_location(self.drawer2, self.user, 'Uninstalling')
|
||||
@@ -671,12 +678,12 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(it.quantity, 255)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
track = stock_events(it).last()
|
||||
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertIn('Counted items', track.notes)
|
||||
|
||||
n = it.tracking_info.count()
|
||||
n = stock_history(it).count()
|
||||
self.assertFalse(
|
||||
it.stocktake(
|
||||
-1,
|
||||
@@ -686,8 +693,8 @@ class StockTest(StockTestBase):
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure tracking info was not added
|
||||
self.assertEqual(it.tracking_info.count(), n)
|
||||
# Ensure no history was added
|
||||
self.assertEqual(stock_history(it).count(), n)
|
||||
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.status, StockStatus.OK.value)
|
||||
@@ -780,9 +787,9 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(it.quantity, n + 45)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
track = stock_events(it).last()
|
||||
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_ADD)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.STOCK_ADD)
|
||||
self.assertIn('Added some items', track.notes)
|
||||
|
||||
self.assertFalse(it.add_stock(-10, None))
|
||||
@@ -810,11 +817,9 @@ class StockTest(StockTestBase):
|
||||
self.assertFalse(ait.location)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=ait).latest('id')
|
||||
track = stock_events(ait).last()
|
||||
|
||||
self.assertEqual(
|
||||
track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
|
||||
)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER)
|
||||
self.assertIn('Allocated some stock', track.notes)
|
||||
|
||||
def test_return_from_customer(self):
|
||||
@@ -869,9 +874,9 @@ class StockTest(StockTestBase):
|
||||
self.assertFalse(ait.sales_order)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=ait).latest('id')
|
||||
track = stock_events(ait).last()
|
||||
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.RETURNED_FROM_CUSTOMER)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.RETURNED_FROM_CUSTOMER)
|
||||
self.assertIn('Stock removed from customer', track.notes)
|
||||
|
||||
# Establish total stock for the part after remove from customer to check that we still have the correct quantity in stock
|
||||
@@ -889,11 +894,11 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(it.quantity, n - 15)
|
||||
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
track = stock_events(it).last()
|
||||
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_REMOVE)
|
||||
self.assertEqual(track.event_type, StockHistoryCode.STOCK_REMOVE)
|
||||
self.assertIn('Removed some items', track.notes)
|
||||
self.assertTrue(it.has_tracking_info)
|
||||
self.assertTrue(stock_events(it).exists())
|
||||
|
||||
# Test that negative quantity does nothing
|
||||
self.assertFalse(it.take_stock(-10, None))
|
||||
@@ -1112,12 +1117,14 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(s1.quantity, 60)
|
||||
self.assertIsNone(s1.purchase_price)
|
||||
|
||||
merge_entry = s1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
merge_entry = stock_events(
|
||||
s1, event_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).first()
|
||||
self.assertIsNotNone(merge_entry)
|
||||
self.assertEqual(merge_entry.deltas['added'], 50.0)
|
||||
self.assertEqual(merge_entry.deltas['quantity'], 60.0)
|
||||
self.assertEqual(merge_entry.additional_data['deltas']['added'], 50.0)
|
||||
self.assertEqual(
|
||||
[Decimal(value) for value in merge_entry.changes_dict['quantity']], [10, 60]
|
||||
)
|
||||
|
||||
part.stock_items.all().delete()
|
||||
|
||||
@@ -1265,107 +1272,70 @@ class StockTest(StockTestBase):
|
||||
self.assertEqual(item.purchase_price_currency, 'GBP')
|
||||
|
||||
|
||||
class TrackingEntryBatchTests(StockTestBase):
|
||||
"""Unit tests for the batch_tracking_entries() context manager."""
|
||||
class TrackingEventBatchTests(StockTestBase):
|
||||
"""Unit tests for batching stock tracking events (audit entries) via write_batched()."""
|
||||
|
||||
def test_entries_queued_and_flushed_on_commit(self):
|
||||
"""Tracking entries created inside batch_tracking_entries() are bulk-created on commit."""
|
||||
def history(self, item):
|
||||
"""Return the audit entries for the provided stock item."""
|
||||
return AuditLogEntry.objects.get_for_object(item).order_by('pk')
|
||||
|
||||
def test_events_written_on_exit(self):
|
||||
"""Tracking events recorded inside write_batched() are written when the context exits."""
|
||||
item = StockItem.objects.get(pk=2)
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
n = self.history(item).count()
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
with transaction.atomic(), batch_tracking_entries():
|
||||
for idx in range(10):
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT,
|
||||
self.user,
|
||||
deltas={'quantity': idx},
|
||||
notes=f'Batch entry {idx}',
|
||||
)
|
||||
with transaction.atomic(), write_batched():
|
||||
for idx in range(10):
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT,
|
||||
self.user,
|
||||
deltas={'quantity': idx},
|
||||
notes=f'Batch entry {idx}',
|
||||
)
|
||||
|
||||
# Nothing should be written yet - the batch only flushes on commit
|
||||
self.assertEqual(StockItemTracking.objects.count(), n)
|
||||
# Nothing should be written yet
|
||||
self.assertEqual(self.history(item).count(), n)
|
||||
|
||||
entries = StockItemTracking.objects.filter(item=item).order_by('id')[:10]
|
||||
self.assertEqual(StockItemTracking.objects.count(), n + 10)
|
||||
entries = list(self.history(item)[n:])
|
||||
self.assertEqual(len(entries), 10)
|
||||
|
||||
for idx, entry in enumerate(entries):
|
||||
self.assertEqual(entry.tracking_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(entry.deltas.get('quantity'), idx)
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(entry.additional_data['deltas']['quantity'], idx)
|
||||
self.assertEqual(entry.notes, f'Batch entry {idx}')
|
||||
|
||||
def test_entries_discarded_on_rollback(self):
|
||||
"""Tracking entries queued in a batch are discarded if the transaction rolls back."""
|
||||
def test_events_discarded_on_rollback(self):
|
||||
"""Tracking events are discarded if the transaction rolls back."""
|
||||
item = StockItem.objects.get(pk=2)
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
n = self.history(item).count()
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
try:
|
||||
with transaction.atomic(), batch_tracking_entries():
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT, self.user, deltas={'quantity': 1}
|
||||
)
|
||||
raise ValueError('boom')
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
with transaction.atomic(), write_batched():
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT, self.user, deltas={'quantity': 1}
|
||||
)
|
||||
raise ValueError('boom')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), n)
|
||||
self.assertEqual(self.history(item).count(), n)
|
||||
|
||||
def test_entries_outside_batch_fire_immediately(self):
|
||||
"""Tracking entries created outside any batch are unaffected - saved immediately."""
|
||||
def test_events_outside_batch_written_immediately(self):
|
||||
"""Tracking events recorded outside any batch are written immediately."""
|
||||
item = StockItem.objects.get(pk=2)
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
n = self.history(item).count()
|
||||
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT, self.user, deltas={'quantity': 1}
|
||||
)
|
||||
|
||||
# No transaction commit or captureOnCommitCallbacks needed - it was never queued
|
||||
self.assertEqual(StockItemTracking.objects.count(), n + 1)
|
||||
self.assertEqual(self.history(item).count(), n + 1)
|
||||
|
||||
def test_nested_batch_share_one_flush(self):
|
||||
"""A nested batch_tracking_entries() call reuses the outer batch, rather than flushing twice."""
|
||||
item = StockItem.objects.get(pk=2)
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
with transaction.atomic(), batch_tracking_entries():
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT, self.user, deltas={'quantity': 1}
|
||||
)
|
||||
with batch_tracking_entries():
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT, self.user, deltas={'quantity': 2}
|
||||
)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n)
|
||||
|
||||
self.assertEqual(StockItemTracking.objects.count(), n + 2)
|
||||
|
||||
def test_commit_false_is_unaffected_by_batch(self):
|
||||
"""commit=False callers keep manual ownership of the entry, even inside a batch."""
|
||||
item = StockItem.objects.get(pk=2)
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
|
||||
with transaction.atomic(), batch_tracking_entries():
|
||||
entry = item.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT,
|
||||
self.user,
|
||||
deltas={'quantity': 1},
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# The entry was returned uncommitted - the batch never saw it, so it was never written
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertIsNone(entry.pk)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n)
|
||||
|
||||
def test_stocktake_batch_tracking_entries(self):
|
||||
"""Integration test: batching stocktake() tracking entries via the StockCountSerializer path."""
|
||||
def test_stocktake_batch(self):
|
||||
"""Integration test: batching stocktake() events via the StockCountSerializer path."""
|
||||
part = Part.objects.create(
|
||||
name='Batch tracking part', description='For batch tracking testing'
|
||||
)
|
||||
@@ -1375,18 +1345,14 @@ class TrackingEntryBatchTests(StockTestBase):
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
n = StockItemTracking.objects.count()
|
||||
with transaction.atomic(), write_batched():
|
||||
for idx, item in enumerate(items):
|
||||
item.stocktake(100 + idx, self.user, notes='Batch stocktake')
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
with transaction.atomic(), batch_tracking_entries():
|
||||
for idx, item in enumerate(items):
|
||||
item.stocktake(100 + idx, self.user, notes='Batch stocktake')
|
||||
|
||||
entries = StockItemTracking.objects.filter(
|
||||
item__in=items, tracking_type=StockHistoryCode.STOCK_COUNT
|
||||
).order_by('id')
|
||||
self.assertEqual(entries.count(), 10)
|
||||
self.assertEqual(StockItemTracking.objects.count(), n + 10)
|
||||
for item in items:
|
||||
entry = self.history(item).last()
|
||||
self.assertEqual(entry.event_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertEqual(entry.notes, 'Batch stocktake')
|
||||
|
||||
|
||||
class StockBarcodeTest(StockTestBase):
|
||||
@@ -2368,7 +2334,7 @@ class StockItemTakeStockConcurrencyTest(TransactionTestCase):
|
||||
lock is acquired) in-memory copy, could both cap to the same amount - the
|
||||
first to acquire the lock removes it all, and the second, upon acquiring
|
||||
the lock, would find nothing left, but would still record a phantom
|
||||
removal in StockItemTracking and report success.
|
||||
removal in the stock history and report success.
|
||||
|
||||
take_stock() now performs the cap *after* lock_quantity() has refreshed
|
||||
self.quantity, so the loser of the race is correctly capped to zero and
|
||||
@@ -2449,8 +2415,7 @@ class StockItemTakeStockConcurrencyTest(TransactionTestCase):
|
||||
# recorded 'removed' amount must not exceed the quantity that
|
||||
# actually existed
|
||||
total_removed = sum(
|
||||
entry.deltas.get('removed', 0)
|
||||
for entry in self.item.tracking_info.all()
|
||||
if entry.deltas
|
||||
(entry.additional_data or {}).get('deltas', {}).get('removed', 0)
|
||||
for entry in stock_events(self.item)
|
||||
)
|
||||
self.assertEqual(total_removed, 10)
|
||||
@@ -148,11 +148,7 @@ def get_ruleset_models() -> dict:
|
||||
'pricing_partinternalpricebreak',
|
||||
],
|
||||
RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'],
|
||||
RuleSetEnum.STOCK: [
|
||||
'stock_stockitem',
|
||||
'stock_stockitemtracking',
|
||||
'stock_stockitemtestresult',
|
||||
],
|
||||
RuleSetEnum.STOCK: ['stock_stockitem', 'stock_stockitemtestresult'],
|
||||
RuleSetEnum.PURCHASE_ORDER: [
|
||||
'company_company',
|
||||
'company_contact',
|
||||
|
||||
@@ -145,7 +145,6 @@ export enum ApiEndpoints {
|
||||
|
||||
// Stock item API endpoints
|
||||
stock_item_list = 'stock/',
|
||||
stock_tracking_list = 'stock/track/',
|
||||
stock_test_result_list = 'stock/test/',
|
||||
stock_transfer = 'stock/transfer/',
|
||||
stock_remove = 'stock/remove/',
|
||||
|
||||
@@ -151,7 +151,7 @@ export const ModelInformationDict: ModelDict = {
|
||||
stockhistory: {
|
||||
label: () => t`Stock History`,
|
||||
label_multiple: () => t`Stock Histories`,
|
||||
api_endpoint: ApiEndpoints.stock_tracking_list,
|
||||
api_endpoint: ApiEndpoints.audit_log_list,
|
||||
icon: 'history'
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -9,25 +9,24 @@ import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { AuditLogTable } from '../../tables/general/AuditLogTable';
|
||||
|
||||
/**
|
||||
* Determine whether the history panel should be displayed on detail pages:
|
||||
* - Only available to staff users
|
||||
* - The audit log must be enabled
|
||||
* - Must be enabled via the 'DISPLAY_HISTORY_TAB' user setting
|
||||
*
|
||||
* Note: The displayed entries are filtered by the permissions of the user
|
||||
*/
|
||||
export function useShowHistoryPanel(): boolean {
|
||||
const user = useUserState();
|
||||
const userSettings = useUserSettingsState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
user.isStaff() &&
|
||||
globalSettings.isSet('AUDIT_LOG_ENABLED') &&
|
||||
userSettings.isSet('DISPLAY_HISTORY_TAB', false),
|
||||
[user, userSettings, globalSettings]
|
||||
[userSettings, globalSettings]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -342,7 +342,6 @@ export function useStockFields({
|
||||
originalId: duplicateStockItem.pk,
|
||||
extraFields: {
|
||||
copy_notes: { value: true },
|
||||
copy_history: { value: false },
|
||||
copy_tests: {
|
||||
value: false,
|
||||
hidden: !duplicateStockItem?.part_detail?.testable
|
||||
|
||||
@@ -297,13 +297,6 @@ export default function SystemSettings() {
|
||||
'STOCKTAKE_DELETE_DAYS'
|
||||
]}
|
||||
/>
|
||||
<GlobalSettingList
|
||||
heading={t`Stock Tracking`}
|
||||
keys={[
|
||||
'STOCK_TRACKING_DELETE_OLD_ENTRIES',
|
||||
'STOCK_TRACKING_DELETE_DAYS'
|
||||
]}
|
||||
/>
|
||||
<GlobalSettingList
|
||||
heading={t`Transfer Orders`}
|
||||
keys={[
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||
|
||||
/*
|
||||
* Render a tooltip for the chart, with correct date information
|
||||
@@ -294,14 +293,6 @@ export default function PartStockHistoryDetail({
|
||||
}: Readonly<{ partId: number }>) {
|
||||
return (
|
||||
<Accordion multiple defaultValue={['stocktake']}>
|
||||
<Accordion.Item value='tracking'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Stock Tracking`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<StockTrackingTable partId={partId} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='stocktake'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Stocktake Entries`}</StylishText>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IconBookmark,
|
||||
IconBoxPadding,
|
||||
IconChecklist,
|
||||
IconHistory,
|
||||
IconInfoCircle,
|
||||
IconPackages,
|
||||
IconShoppingCart,
|
||||
@@ -41,6 +40,9 @@ import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import HistoryPanel, {
|
||||
useShowHistoryPanel
|
||||
} from '../../components/panels/HistoryPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import LocateItemButton from '../../components/plugins/LocateItemButton';
|
||||
@@ -69,7 +71,6 @@ import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTa
|
||||
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
import { StockDetailsPanel } from './StockDetailsPanel';
|
||||
|
||||
@@ -182,6 +183,8 @@ export default function StockDetail() {
|
||||
return stockitem?.part_detail?.assembly;
|
||||
}, [trackedBomItemQuery, stockitem]);
|
||||
|
||||
const showHistory = useShowHistoryPanel();
|
||||
|
||||
const stockPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -197,16 +200,6 @@ export default function StockDetail() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'tracking',
|
||||
label: t`Stock Tracking`,
|
||||
icon: <IconHistory />,
|
||||
content: stockitem.pk ? (
|
||||
<StockTrackingTable itemId={stockitem.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'allocations',
|
||||
label: t`Allocations`,
|
||||
@@ -320,9 +313,15 @@ export default function StockDetail() {
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk,
|
||||
note_count: instanceInfo.note_count
|
||||
}),
|
||||
HistoryPanel({
|
||||
model_type: ModelType.stockitem,
|
||||
model_id: stockitem.pk,
|
||||
visible: showHistory
|
||||
})
|
||||
];
|
||||
}, [
|
||||
showHistory,
|
||||
showSalesAllocations,
|
||||
showBuildAllocations,
|
||||
showInstalledItems,
|
||||
|
||||
@@ -233,6 +233,86 @@ function getChanges(record: any): AuditChange[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human readable label for an event delta key (e.g. 'sales_order' -> 'Sales order')
|
||||
*/
|
||||
function deltaLabel(key: string): string {
|
||||
// Labels for commonly used delta keys (e.g. stock tracking events)
|
||||
const labels: Record<string, string> = {
|
||||
added: t`Added`,
|
||||
build_order: t`Build Order`,
|
||||
buildorder: t`Build Order`,
|
||||
customer: t`Customer`,
|
||||
location: t`Location`,
|
||||
part: t`Part`,
|
||||
purchaseorder: t`Purchase Order`,
|
||||
quantity: t`Quantity`,
|
||||
removed: t`Removed`,
|
||||
returnorder: t`Return Order`,
|
||||
salesorder: t`Sales Order`,
|
||||
stockitem: t`Stock Item`,
|
||||
transferorder: t`Transfer Order`
|
||||
};
|
||||
|
||||
if (key in labels) {
|
||||
return labels[key];
|
||||
}
|
||||
|
||||
const label = key.replaceAll('_', ' ');
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the deltas and notes recorded against a named event (if any)
|
||||
*/
|
||||
function AuditEventDetails({
|
||||
record,
|
||||
navigate
|
||||
}: Readonly<{
|
||||
record: any;
|
||||
navigate: NavigateFunction;
|
||||
}>): ReactNode {
|
||||
const deltas: [string, any][] = Object.entries(record.deltas ?? {});
|
||||
|
||||
if (deltas.length == 0 && !record.notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{deltas.map(([key, value]) => {
|
||||
// A reference to another object: {model_type, pk, str}
|
||||
const isReference =
|
||||
value !== null && typeof value === 'object' && 'model_type' in value;
|
||||
|
||||
return (
|
||||
<Group key={key} gap='xs' wrap='nowrap'>
|
||||
<Text size='sm' fw={500}>
|
||||
{deltaLabel(key)}:
|
||||
</Text>
|
||||
{isReference && !value.str ? (
|
||||
<Text size='sm' c='dimmed' fs='italic'>{t`Hidden`}</Text>
|
||||
) : (
|
||||
<AuditValue
|
||||
value={isReference ? value.pk : value}
|
||||
display={isReference ? value.str : undefined}
|
||||
modelType={isReference ? value.model_type : undefined}
|
||||
numeric={typeof value === 'number'}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{record.notes && (
|
||||
<Text size='sm' fs='italic'>
|
||||
{record.notes}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a summary of the changes for an audit entry
|
||||
*/
|
||||
@@ -243,17 +323,30 @@ function AuditChangesSummary({
|
||||
record: any;
|
||||
navigate: NavigateFunction;
|
||||
}>): ReactNode {
|
||||
const eventDetails = (
|
||||
<AuditEventDetails record={record} navigate={navigate} />
|
||||
);
|
||||
|
||||
if (record.action == AuditAction.DELETE) {
|
||||
// A named event may be recorded against the deletion (e.g. stock depleted to zero)
|
||||
return (
|
||||
<Text size='sm' c='dimmed' fs='italic'>
|
||||
{t`Object was deleted`}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{eventDetails}
|
||||
<Text size='sm' c='dimmed' fs='italic'>
|
||||
{t`Object was deleted`}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const changes = getChanges(record);
|
||||
|
||||
if (changes.length == 0) {
|
||||
// A named event may be recorded without any field changes
|
||||
if (record.event_type !== null && record.event_type !== undefined) {
|
||||
return eventDetails;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size='sm' c='dimmed' fs='italic'>
|
||||
{t`No changes recorded`}
|
||||
@@ -263,6 +356,7 @@ function AuditChangesSummary({
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{eventDetails}
|
||||
{changes.slice(0, MAX_INLINE_CHANGES).map((change) => (
|
||||
<AuditChangeItem
|
||||
key={change.field}
|
||||
@@ -450,7 +544,7 @@ export function AuditLogTable({
|
||||
switchable: false,
|
||||
render: (record: any) => (
|
||||
<Badge color={actionColor(record.action)} variant='light'>
|
||||
{actionLabel(record.action)}
|
||||
{record.event_label ?? actionLabel(record.action)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Table, Text } from '@mantine/core';
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { RenderBuildOrder } from '../../components/render/Build';
|
||||
import { RenderCompany } from '../../components/render/Company';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
RenderSalesOrder,
|
||||
RenderTransferOrder
|
||||
} from '../../components/render/Order';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../../components/render/Stock';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import {
|
||||
DateColumn,
|
||||
DescriptionColumn,
|
||||
IPNColumn,
|
||||
PartColumn,
|
||||
StockColumn
|
||||
} from '../../components/tables/ColumnRenderers';
|
||||
import {
|
||||
IncludeVariantsFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
UserFilter
|
||||
} from '../../components/tables/Filter';
|
||||
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
|
||||
|
||||
type StockTrackingEntry = {
|
||||
label: string;
|
||||
key: string;
|
||||
details: ReactNode;
|
||||
};
|
||||
|
||||
export function StockTrackingTable({
|
||||
itemId,
|
||||
partId
|
||||
}: Readonly<{
|
||||
itemId?: number;
|
||||
partId?: number;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const table = useTable(partId ? 'part_stock_tracking' : 'stock_tracking');
|
||||
|
||||
// Render "details" for a stock tracking record
|
||||
const renderDetails = useCallback(
|
||||
(record: any) => {
|
||||
const deltas: any = record?.deltas ?? {};
|
||||
|
||||
const entries: StockTrackingEntry[] = [
|
||||
{
|
||||
label: t`Stock Item`,
|
||||
key: 'stockitem',
|
||||
details:
|
||||
deltas.stockitem_detail &&
|
||||
RenderStockItem({ instance: deltas.stockitem_detail, link: true })
|
||||
},
|
||||
{
|
||||
label: t`Stock Item`,
|
||||
key: 'item',
|
||||
details:
|
||||
deltas.item_detail &&
|
||||
RenderStockItem({
|
||||
instance: deltas.item_detail,
|
||||
link: true
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Status`,
|
||||
key: 'status',
|
||||
details:
|
||||
deltas.status &&
|
||||
StatusRenderer({
|
||||
status: deltas.status,
|
||||
type: ModelType.stockitem,
|
||||
fallbackStatus: deltas.status_logical
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Old Status`,
|
||||
key: 'old_status',
|
||||
details:
|
||||
deltas.old_status &&
|
||||
StatusRenderer({
|
||||
status: deltas.old_status,
|
||||
type: ModelType.stockitem,
|
||||
fallbackStatus: deltas.old_status_logical
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Quantity`,
|
||||
key: 'quantity',
|
||||
details: formatDecimal(deltas.quantity)
|
||||
},
|
||||
{
|
||||
label: t`Added`,
|
||||
key: 'added',
|
||||
details: deltas.added
|
||||
},
|
||||
{
|
||||
label: t`Removed`,
|
||||
key: 'removed',
|
||||
details: deltas.removed
|
||||
},
|
||||
{
|
||||
label: t`Part`,
|
||||
key: 'part',
|
||||
details:
|
||||
deltas.part_detail &&
|
||||
RenderPart({
|
||||
instance: deltas.part_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Location`,
|
||||
key: 'location',
|
||||
details:
|
||||
deltas.location_detail &&
|
||||
RenderStockLocation({
|
||||
instance: deltas.location_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Build Order`,
|
||||
key: 'buildorder',
|
||||
details:
|
||||
deltas.buildorder_detail &&
|
||||
RenderBuildOrder({
|
||||
instance: deltas.buildorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Purchase Order`,
|
||||
key: 'purchaseorder',
|
||||
details:
|
||||
deltas.purchaseorder_detail &&
|
||||
RenderPurchaseOrder({
|
||||
instance: deltas.purchaseorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Sales Order`,
|
||||
key: 'salesorder',
|
||||
details:
|
||||
deltas.salesorder_detail &&
|
||||
RenderSalesOrder({
|
||||
instance: deltas.salesorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Return Order`,
|
||||
key: 'returnorder',
|
||||
details:
|
||||
deltas.returnorder_detail &&
|
||||
RenderReturnOrder({
|
||||
instance: deltas.returnorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Transfer Order`,
|
||||
key: 'transferorder',
|
||||
details:
|
||||
deltas.transferorder_detail &&
|
||||
RenderTransferOrder({
|
||||
instance: deltas.transferorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Customer`,
|
||||
key: 'customer',
|
||||
details:
|
||||
deltas.customer_detail &&
|
||||
RenderCompany({
|
||||
instance: deltas.customer_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Table striped>
|
||||
<Table.Tbody>
|
||||
{entries.map(
|
||||
(entry) =>
|
||||
entry.details && (
|
||||
<Table.Tr key={entry.key}>
|
||||
<Table.Td>
|
||||
<Text>{entry.label}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{entry.details}</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const filters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
IncludeVariantsFilter(),
|
||||
UserFilter({
|
||||
name: 'user',
|
||||
label: t`User`,
|
||||
description: t`Filter by user`
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
DateColumn({
|
||||
switchable: false
|
||||
}),
|
||||
PartColumn({
|
||||
title: t`Part`,
|
||||
part: 'part_detail',
|
||||
switchable: true,
|
||||
hidden: !partId
|
||||
}),
|
||||
IPNColumn({
|
||||
defaultVisible: false,
|
||||
hidden: !partId
|
||||
}),
|
||||
StockColumn({
|
||||
title: t`Stock Item`,
|
||||
accessor: 'item_detail',
|
||||
nullMessage: (
|
||||
<Text size='sm' c='red'>{t`Stock item no longer exists`}</Text>
|
||||
),
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
hidden: !partId
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'label'
|
||||
}),
|
||||
{
|
||||
accessor: 'details',
|
||||
title: t`Details`,
|
||||
switchable: false,
|
||||
render: renderDetails
|
||||
},
|
||||
{
|
||||
accessor: 'notes',
|
||||
title: t`Notes`,
|
||||
sortable: false,
|
||||
switchable: true
|
||||
},
|
||||
{
|
||||
accessor: 'user',
|
||||
title: t`User`,
|
||||
render: (record: any) => {
|
||||
if (!record.user_detail) {
|
||||
return <Text size='sm' fs='italic'>{t`No user information`}</Text>;
|
||||
}
|
||||
|
||||
return RenderUser({ instance: record.user_detail });
|
||||
}
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.stock_tracking_list)}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
item: itemId,
|
||||
part: partId,
|
||||
part_detail: partId ? true : undefined,
|
||||
item_detail: partId ? true : undefined,
|
||||
user_detail: true
|
||||
},
|
||||
enableDownload: true,
|
||||
tableFilters: filters,
|
||||
modelType: partId ? ModelType.stockitem : undefined,
|
||||
modelField: 'item'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import { adminuser, stevenuser } from '../defaults.js';
|
||||
import {
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
@@ -30,7 +30,6 @@ test('Stock - Basic Tests', async ({ browser }) => {
|
||||
await navigate(page, 'stock/item/1194/details');
|
||||
await page.getByText('D.123 | Doohickey').waitFor();
|
||||
await page.getByText('Batch Code: BX-123-2024-2-7').waitFor();
|
||||
await loadTab(page, 'Stock Tracking');
|
||||
await loadTab(page, 'Test Results');
|
||||
await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
|
||||
await loadTab(page, 'Installed Items');
|
||||
@@ -47,12 +46,6 @@ test('Stock - Basic Tests', async ({ browser }) => {
|
||||
|
||||
// Automatically navigate through to the newly created stock item
|
||||
await page.getByText('Quantity: 987').first().waitFor();
|
||||
await loadTab(page, 'Stock Tracking');
|
||||
await page
|
||||
.getByRole('cell', { name: 'Stock item created' })
|
||||
.first()
|
||||
.waitFor();
|
||||
await page.getByRole('cell', { name: 'allaccess Ally Access' }).waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Location Tree', async ({ browser }) => {
|
||||
@@ -508,24 +501,59 @@ test('Stock - Return Items', async ({ browser }) => {
|
||||
await page.getByText('This field is required.').first().waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Tracking', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/176/details' });
|
||||
test('Stock - History (Stock Item)', async ({ browser }) => {
|
||||
// Note: The 'admin' user has the "Show Change History" setting enabled
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: adminuser,
|
||||
url: 'stock/item/193/history'
|
||||
});
|
||||
|
||||
await page.getByText('widget.purple | Purple Widget').first().waitFor();
|
||||
|
||||
// Stock tracking events are displayed in the "History" tab
|
||||
await page.getByText('Installed into assembly').first().waitFor();
|
||||
await page.getByText('Assigned serial number').first().waitFor();
|
||||
await page.getByText('Stock item created').first().waitFor();
|
||||
|
||||
// Event deltas link to the related stock item
|
||||
await page.getByText('Widget Board (assembled) #159').first().waitFor();
|
||||
});
|
||||
|
||||
test('Stock - History (Part)', async ({ browser }) => {
|
||||
// Note: The 'admin' user has the "Show Change History" setting enabled
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: adminuser,
|
||||
url: 'part/76/history'
|
||||
});
|
||||
|
||||
await page.getByText('widget.purple | Purple Widget').first().waitFor();
|
||||
|
||||
// The history of the part includes stock tracking events for its stock items
|
||||
await page.getByText('Shipped against Sales Order').first().waitFor();
|
||||
await page.getByText('Customer D').first().waitFor();
|
||||
await page.getByText('SO0009').first().waitFor();
|
||||
await page.getByText('Installed into assembly').first().waitFor();
|
||||
await page.getByText('Purple Widget #35').first().waitFor();
|
||||
|
||||
// ...as well as changes to the part itself (e.g. parameters)
|
||||
await page.getByText('Color: Purple').first().waitFor();
|
||||
});
|
||||
|
||||
test('Stock - History', async ({ browser }) => {
|
||||
// Note: The 'admin' user has the "Show Change History" setting enabled
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: adminuser,
|
||||
url: 'stock/item/176/details'
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: 'Widget Assembly # 2' }).waitFor();
|
||||
|
||||
// Navigate to the "stock tracking" tab
|
||||
await loadTab(page, 'Stock Tracking');
|
||||
|
||||
await page.getByText('Factory/Office Block/Room').first().waitFor();
|
||||
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||
|
||||
/* Add some more stock items and tracking information:
|
||||
/* Add some more stock items and history information:
|
||||
* - Duplicate this stock item
|
||||
* - Give it a unique serial number
|
||||
* - Ensure the tracking information is duplicated correctly
|
||||
* - Ensure the creation of the new item is recorded
|
||||
* - Delete the new stock item
|
||||
* - Ensure that the tracking information is retained against the base part
|
||||
* - Ensure that the history is retained against the base part
|
||||
*/
|
||||
|
||||
// Duplicate the stock item
|
||||
@@ -540,13 +568,10 @@ test('Stock - Tracking', async ({ browser }) => {
|
||||
.fill('9876');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Check stock tracking information is correct
|
||||
// Check that the creation of the new item is recorded
|
||||
await page.getByText('Serial Number: 9876').first().waitFor();
|
||||
await loadTab(page, 'Stock Tracking');
|
||||
await page
|
||||
.getByRole('cell', { name: 'Stock item created' })
|
||||
.first()
|
||||
.waitFor();
|
||||
await loadTab(page, 'History', true);
|
||||
await page.getByText('Stock item created').first().waitFor();
|
||||
|
||||
// Delete this stock item
|
||||
await page
|
||||
@@ -557,17 +582,10 @@ test('Stock - Tracking', async ({ browser }) => {
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check stock tracking for base part
|
||||
await loadTab(page, 'Stock History');
|
||||
await page.getByRole('button', { name: 'Stock Tracking' }).click();
|
||||
|
||||
await page.getByText('Stock item no longer exists').first().waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Thumbnail Blue Widget' })
|
||||
.first()
|
||||
.waitFor();
|
||||
|
||||
await page.getByText('# 162').first().waitFor();
|
||||
// The history of the deleted item is retained against the base part
|
||||
await loadTab(page, 'History', true);
|
||||
await page.getByText('Object was deleted').first().waitFor();
|
||||
await page.getByText('Stock item created').first().waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Location', async ({ browser }) => {
|
||||
|
||||
Reference in new issue
Block a user