Merge branch 'master' into block-notes

This commit is contained in:
Oliver 2026-05-21 19:59:00 +10:00 committed by GitHub
commit fd3d64de99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 68027 additions and 64813 deletions

View File

@ -174,7 +174,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # pin@v4.0.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # pin@v4.1.1
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # pin@v4.1.2
- name: Check if Dockerhub login is required
id: docker_login
run: |

View File

@ -222,7 +222,7 @@ jobs:
echo "Downloaded api.yaml"
- name: Running OpenAPI Spec diff action
id: breaking_changes
uses: oasdiff/oasdiff-action/diff@e7609f171f253c3e2be2a2e5c508f749ca476a07 # pin@main
uses: oasdiff/oasdiff-action/diff@6147a58e5d1249a12f42fc864ab791d571a30015 # pin@main
with:
base: "api.yaml"
revision: "src/backend/InvenTree/schema.yml"
@ -363,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # pin@v4.15.0
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
# check if we are in inventree/inventree - reporting only works in that OIDC context
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
with:
@ -454,7 +454,7 @@ jobs:
env:
node_version: '>=24'
- name: Performance Reporting
uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # pin@v4.15.0
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # pin@v4.15.1
with:
mode: walltime
run: inv dev.test --pytest

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: results.sarif

View File

@ -209,6 +209,7 @@ Configuration of stock item options
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
{{ globalsetting("STOCK_ALLOW_EDIT_SERIAL") }}
{{ globalsetting("STOCK_ALLOW_DELETE_SERIALIZED") }}
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}

View File

@ -142,6 +142,8 @@ Note that any serial number adjustments are subject to the same validation rules
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
!!! info "Disable Serial Number Editing"
If you wish to prevent users from editing serial numbers, this can be achieved by disabling the `Allow Edit Serial Number` setting in the system settings view.
#### Plugin Support

View File

@ -480,9 +480,9 @@ pygments==2.20.0 \
# via
# mkdocs-material
# rich
pymdown-extensions==10.21.2 \
--hash=sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638 \
--hash=sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc
pymdown-extensions==10.21.3 \
--hash=sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354 \
--hash=sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6
# via
# mkdocs-material
# mkdocs-mermaid2-plugin

View File

@ -696,6 +696,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': True,
'validator': bool,
},
'STOCK_ALLOW_EDIT_SERIAL': {
'name': _('Allow Edit Serial Number'),
'description': _('Allow editing of serial number for stock items'),
'default': True,
'validator': bool,
},
'STOCK_ALLOW_DELETE_SERIALIZED': {
'name': _('Delete Serialized Stock'),
'description': _('Allow deletion of stock items which have a serial number'),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,13 @@
from __future__ import annotations
import decimal
import hashlib
import inspect
import math
import os
import re
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import TypedDict, cast
from django.conf import settings
@ -2053,8 +2052,9 @@ class Part(
'part', 'sub_part'
)
for item in bom_items:
item.validate_hash(valid=valid)
if valid:
for item in bom_items:
item.validate_hash(valid=True)
self.bom_validated = valid
self.bom_checksum = self.get_bom_hash() if valid else ''
@ -2234,8 +2234,8 @@ class Part(
logger.warning('WARNING: BomItem ID %s contains itself in BOM', item.pk)
continue
q = decimal.Decimal(quantity)
i = decimal.Decimal(item.quantity)
q = Decimal(quantity)
i = Decimal(item.quantity)
prices = item.sub_part.get_price_range(
q * i, internal=internal, purchase=purchase
@ -3935,14 +3935,18 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
'raw_amount': _('Quantity must be greater than zero')
})
self.quantity = Decimal(quantity.magnitude)
# Normalize the quantity, to maximum 5 decimal places
quantity = Decimal(quantity.magnitude)
except ValidationError as e:
raise ValidationError({'raw_amount': e.messages})
# Ensure that the raw_amount is converted to a Decimal value
# and quantized to a maximum of 5 decimal places (to avoid floating point issues)
try:
self.quantity = Decimal(self.quantity)
self.quantity = Decimal(quantity).quantize(
Decimal('0.00001'), rounding=ROUND_HALF_UP
)
except InvalidOperation:
msg = _('Invalid quantity provided')
raise ValidationError({'quantity': msg, 'raw_amount': msg})

View File

@ -450,6 +450,81 @@ class StockItem(
order_insertion_by = ['part']
def save(self, *args, **kwargs):
"""Save this StockItem to the database.
Performs a number of checks:
- Unique serial number requirement
- Adds a transaction note when the item is first created.
"""
self.validate_unique()
self.clean()
self.update_serial_number()
user = kwargs.pop('user', None)
if user is None:
user = getattr(self, '_user', None)
# If 'add_note = False' specified, then no tracking note will be added for item creation
add_note = kwargs.pop('add_note', True)
notes = kwargs.pop('notes', '')
if self.pk:
# StockItem has already been saved
# Check if "interesting" fields have been changed
# (we wish to record these as historical records)
try:
old = StockItem.objects.get(pk=self.pk)
old_custom_status = old.get_custom_status()
custom_status = self.get_custom_status()
deltas = {}
# Status changed?
if old.status != self.status:
# Custom status changed?
# Matches custom status tracking behavior of StockChangeStatusSerializer
if old_custom_status != custom_status:
deltas['status'] = custom_status
deltas['status_logical'] = self.status
else:
deltas['status'] = self.status
deltas['status_logical'] = self.status
if old_custom_status:
deltas['old_status'] = old_custom_status
deltas['old_status_logical'] = old.status
else:
deltas['old_status'] = old.status
deltas['old_status_logical'] = old.status
if add_note and len(deltas) > 0:
self.add_tracking_entry(
StockHistoryCode.EDITED, user, deltas=deltas, notes=notes
)
except (ValueError, StockItem.DoesNotExist):
pass
super().save(*args, **kwargs)
# If user information is provided, and no existing note exists, create one!
if add_note and self.tracking_info.count() == 0:
tracking_info = {'status': self.status}
self.add_tracking_entry(
StockHistoryCode.CREATED,
user,
deltas=tracking_info,
notes=notes,
location=self.location,
quantity=float(self.quantity),
)
def delete(self, ignore_serial_check: bool = False, **kwargs):
"""Custom delete method for StockItem model.
@ -785,82 +860,6 @@ class StockItem(
"""Return the 'previous' stock item (based on serial number)."""
return self.get_next_serialized_item(reverse=True)
def save(self, *args, **kwargs):
"""Save this StockItem to the database.
Performs a number of checks:
- Unique serial number requirement
- Adds a transaction note when the item is first created.
"""
self.validate_unique()
self.clean()
self.update_serial_number()
user = kwargs.pop('user', None)
if user is None:
user = getattr(self, '_user', None)
# If 'add_note = False' specified, then no tracking note will be added for item creation
add_note = kwargs.pop('add_note', True)
notes = kwargs.pop('notes', '')
if self.pk:
# StockItem has already been saved
# Check if "interesting" fields have been changed
# (we wish to record these as historical records)
try:
old = StockItem.objects.get(pk=self.pk)
old_custom_status = old.get_custom_status()
custom_status = self.get_custom_status()
deltas = {}
# Status changed?
if old.status != self.status:
# Custom status changed?
# Matches custom status tracking behavior of StockChangeStatusSerializer
if old_custom_status != custom_status:
deltas['status'] = custom_status
deltas['status_logical'] = self.status
else:
deltas['status'] = self.status
deltas['status_logical'] = self.status
if old_custom_status:
deltas['old_status'] = old_custom_status
deltas['old_status_logical'] = old.status
else:
deltas['old_status'] = old.status
deltas['old_status_logical'] = old.status
if add_note and len(deltas) > 0:
self.add_tracking_entry(
StockHistoryCode.EDITED, user, deltas=deltas, notes=notes
)
except (ValueError, StockItem.DoesNotExist):
pass
super().save(*args, **kwargs)
# If user information is provided, and no existing note exists, create one!
if add_note and self.tracking_info.count() == 0:
tracking_info = {'status': self.status}
self.add_tracking_entry(
StockHistoryCode.CREATED,
user,
deltas=tracking_info,
notes=notes,
location=self.location,
quantity=float(self.quantity),
)
@property
def status_label(self):
"""Return label."""
@ -937,6 +936,17 @@ class StockItem(
if type(self.batch) is str:
self.batch = self.batch.strip()
if not get_global_setting('STOCK_ALLOW_EDIT_SERIAL'):
deltas = self.get_field_deltas()
# Prevent editing of serial numbers if the item already has a serial number assigned
if 'serial' in deltas and deltas['serial']['old'] not in [None, '']:
raise ValidationError({
'serial': _(
'Editing of serial numbers is not allowed - this item has already been assigned a serial number'
)
})
# Custom validation of batch code
self.validate_batch_code()

View File

@ -1644,6 +1644,36 @@ class StockItemTest(StockAPITestCase):
)
self.assertEqual(trackable_part.get_stock_count(), 10)
def test_edit_serial(self):
"""Test that we can edit serial numbers via the API."""
item = StockItem.objects.create(
part=Part.objects.filter(trackable=True).first(),
quantity=1,
location=StockLocation.objects.first(),
)
set_global_setting('STOCK_ALLOW_EDIT_SERIAL', False)
url = reverse('api-stock-detail', kwargs={'pk': item.pk})
# Edit the serial number
# This should succeed, as the initial serial number is blank
response = self.patch(url, {'serial': '54321'}, expected_code=200)
self.assertEqual(response.data['serial'], '54321')
# Edit it again - this time, should fail as the serial number is already set
response = self.patch(url, {'serial': '98765'}, expected_code=400)
self.assertIn('Editing of serial numbers is not allowed', str(response.data))
# Ensure that changing a different field does not cause an error
response = self.patch(url, {'batch': 'abcde'}, expected_code=200)
self.assertEqual(response.data['batch'], 'abcde')
# Adjust the setting to allow serial editing
set_global_setting('STOCK_ALLOW_EDIT_SERIAL', True)
response = self.patch(url, {'serial': '98765'}, expected_code=200)
self.assertEqual(response.data['serial'], '98765')
def test_default_expiry(self):
"""Test that the "default_expiry" functionality works via the API.

View File

@ -232,6 +232,7 @@ export function useStockFields({
serial: {
placeholderAutofill: true,
placeholder: serialGenerator.result,
disabled: !create && !globalSettings.isSet('STOCK_ALLOW_EDIT_SERIAL'),
hidden:
create ||
partInstance.trackable == false ||

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -251,6 +251,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
'STOCK_ALLOW_EDIT_SERIAL',
'STOCK_ALLOW_DELETE_SERIALIZED',
'STOCK_DELETE_DEPLETED_DEFAULT',
'STOCK_BATCH_CODE_TEMPLATE',