feat(stock): add exclude_from_stock option to StockLocation (#7064) (#13166)

* feat(stock): add exclude_from_stock option to StockLocation (#7064)

* feat(stock): add UI detail, table filters, API version bump, and prek formatting (#7064)

---------

Co-authored-by: UGREEN USER <jaharoni@debian.dedomain>
Co-authored-by: WholeTroutMedia <cortex@inceptionengine.systems>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 12:06:26 +11:00
1 parent d5d0bfabc4
commit d01645c5fb
12 files changed
+105 -3

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#13166](https://github.com/inventree/InvenTree/pull/13166) Adds an "exclude_from_stock" attribute to the StockLocation model, allowing stock items in specified locations to be excluded from stock availability calculations.
### Changed
### Removed
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 570
INVENTREE_API_VERSION = 571
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v571 -> 2026-11-03 : https://github.com/inventree/InvenTree/pull/13166
- Adds "exclude_from_stock" field and filter to the StockLocation API endpoint
- Adds "exclude_from_stock" filter to the StockItem API endpoint
v570 -> 2026-11-03 : https://github.com/inventree/InvenTree/pull/13175
- Token API (/api/user/tokens/): "revoked", "revoked_by", "issued_by", "token_version", "revocation_reason" and "last_seen" fields are read-only
- Token API (/api/user/tokens/): "expiry" must be between today and 365 days in the future
+11 -1
View File
@@ -310,7 +310,7 @@ class StockLocationFilter(FilterSet):
"""Meta class options for this filterset."""
model = StockLocation
fields = ['name', 'structural', 'external']
fields = ['name', 'structural', 'external', 'exclude_from_stock']
location_type = rest_filters.ModelChoiceFilter(
queryset=StockLocationType.objects.all(), field_name='location_type'
@@ -765,6 +765,16 @@ class StockFilter(FilterSet):
return queryset.filter(location__external=True)
return queryset.exclude(location__external=True)
exclude_from_stock = rest_filters.BooleanFilter(
label=_('Exclude from Stock'), method='filter_exclude_from_stock'
)
def filter_exclude_from_stock(self, queryset, name, value):
"""Filter by whether or not the stock item is located in an exclude_from_stock location."""
if str2bool(value):
return queryset.filter(location__exclude_from_stock=True)
return queryset.exclude(location__exclude_from_stock=True)
in_stock = rest_filters.BooleanFilter(label='In Stock', method='filter_in_stock')
def filter_in_stock(self, queryset, name, value):
@@ -0,0 +1,19 @@
# Generated by Django 5.2.17 on 2026-11-02 21:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('stock', '0130_delete_stockitemtracking')]
operations = [
migrations.AddField(
model_name='stocklocation',
name='exclude_from_stock',
field=models.BooleanField(
default=False,
help_text='If checked, stock items in this location will not be counted in available stock',
verbose_name='Exclude from Stock',
),
)
]
+12 -1
View File
@@ -213,6 +213,14 @@ class StockLocation(
help_text=_('This is an external stock location'),
)
exclude_from_stock = models.BooleanField(
default=False,
verbose_name=_('Exclude from Stock'),
help_text=_(
'If checked, stock items in this location will not be counted in available stock'
),
)
location_type = models.ForeignKey(
StockLocationType,
on_delete=models.SET_NULL,
@@ -658,7 +666,7 @@ class StockItem(
consumed_by=None,
is_building=False,
status__in=StockStatusGroups.AVAILABLE_CODES,
)
) & (Q(location=None) | Q(location__exclude_from_stock=False))
@classmethod
def get_expired_filter(cls):
@@ -2318,6 +2326,9 @@ class StockItem(
if check_status and self.status not in StockStatusGroups.AVAILABLE_CODES:
return False
if check_status and self.location and self.location.exclude_from_stock:
return False
if check_quantity and self.quantity <= 0:
return False
@@ -1428,6 +1428,7 @@ class LocationSerializer(
'custom_icon',
'structural',
'external',
'exclude_from_stock',
'location_type',
# Optional fields
'location_type_detail',
+8
View File
@@ -981,6 +981,14 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock(external=False)
self.assertEqual(len(response), 28)
def test_filter_exclude_from_stock(self):
"""Filter StockItem by exclude_from_stock."""
response = self.get_stock(exclude_from_stock=True)
self.assertEqual(len(response), 0)
response = self.get_stock(exclude_from_stock=False)
self.assertEqual(len(response), 29)
def test_filter_available(self):
"""Filter StockItem by available."""
response = self.get_stock(available=True)
+27
View File
@@ -1930,6 +1930,33 @@ class StockLocationTest(InvenTreeTestCase):
loc.save()
self.assertEqual(loc.icon, '')
def test_exclude_from_stock(self):
"""Test that stock items in locations with exclude_from_stock=True are excluded from available stock."""
part = Part.objects.create(
name='RnD Test Part', description='Part for RnD stock test'
)
normal_loc = StockLocation.objects.create(
name='Warehouse A', exclude_from_stock=False
)
rnd_loc = StockLocation.objects.create(name='RnD Lab', exclude_from_stock=True)
item_normal = StockItem.objects.create(
part=part, location=normal_loc, quantity=10
)
item_rnd = StockItem.objects.create(part=part, location=rnd_loc, quantity=25)
self.assertTrue(item_normal.in_stock)
self.assertFalse(item_rnd.in_stock)
self.assertEqual(part.total_stock, 10)
self.assertEqual(part.available_stock, 10)
rnd_loc.exclude_from_stock = False
rnd_loc.save()
item_rnd.refresh_from_db()
self.assertTrue(item_rnd.in_stock)
self.assertEqual(part.total_stock, 35)
self.assertEqual(part.available_stock, 35)
class AdminTest(AdminTestCase):
"""Tests for the admin interface integration."""
+1
View File
@@ -2195,6 +2195,7 @@ export function useStockLocationFields(): ApiFormFieldSet {
},
structural: {},
external: {},
exclude_from_stock: {},
custom_icon: {
field_type: 'icon'
},
@@ -78,6 +78,11 @@ export function StockLocationDetailsPanel({
name: 'external',
label: t`External`
},
{
type: 'boolean',
name: 'exclude_from_stock',
label: t`Exclude from Stock`
},
{
type: 'string',
name: 'location_type_detail.name',
@@ -320,6 +320,11 @@ function stockItemTableFilters({
label: t`External Location`,
description: t`Show items in an external location`
},
{
name: 'exclude_from_stock',
label: t`Exclude from Stock`,
description: t`Show items in a location excluded from stock calculations`
},
TagsFilter({ modelType: ModelType.stockitem })
];
}
@@ -51,6 +51,11 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
label: t`External`,
description: t`Show external locations`
},
{
name: 'exclude_from_stock',
label: t`Exclude from Stock`,
description: t`Show locations excluded from stock calculations`
},
{
name: 'has_location_type',
label: t`Has location type`
@@ -97,6 +102,10 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) {
accessor: 'external',
defaultVisible: false
}),
BooleanColumn({
accessor: 'exclude_from_stock',
defaultVisible: false
}),
{
accessor: 'location_type',
sortable: false,