mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
Feature/location types (#5588)
* Added model changes for StockLocationTypes * Implement icon for CUI * Added location type to location table with filters * Fix ruleset * Added tests * Bump api version to v136 * trigger: ci * Bump api version variable too
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
"""Admin for stock app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from import_export import widgets
|
||||
@ -14,7 +15,7 @@ from order.models import PurchaseOrder, SalesOrder
|
||||
from part.models import Part
|
||||
|
||||
from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
StockItemTracking, StockLocation, StockLocationType)
|
||||
|
||||
|
||||
class LocationResource(InvenTreeResource):
|
||||
@ -77,6 +78,23 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class LocationTypeAdmin(admin.ModelAdmin):
|
||||
"""Admin class for StockLocationType."""
|
||||
|
||||
list_display = ('name', 'description', 'icon', 'location_count')
|
||||
readonly_fields = ('location_count', )
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Annotate queryset to fetch location count."""
|
||||
return super().get_queryset(request).annotate(
|
||||
location_count=Count("stock_locations"),
|
||||
)
|
||||
|
||||
def location_count(self, obj):
|
||||
"""Returns the number of locations this location type is assigned to."""
|
||||
return obj.location_count
|
||||
|
||||
|
||||
class StockItemResource(InvenTreeResource):
|
||||
"""Class for managing StockItem data import/export."""
|
||||
|
||||
@ -204,6 +222,7 @@ class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(StockLocation, LocationAdmin)
|
||||
admin.site.register(StockLocationType, LocationTypeAdmin)
|
||||
admin.site.register(StockItem, StockItemAdmin)
|
||||
admin.site.register(StockItemTracking, StockTrackingAdmin)
|
||||
admin.site.register(StockItemAttachment, StockAttachmentAdmin)
|
||||
|
@ -41,7 +41,7 @@ from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.admin import LocationResource, StockItemResource
|
||||
from stock.models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
StockItemTracking, StockLocation, StockLocationType)
|
||||
|
||||
|
||||
class StockDetail(RetrieveUpdateDestroyAPI):
|
||||
@ -222,6 +222,25 @@ class StockMerge(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationFilter(rest_filters.FilterSet):
|
||||
"""Base class for custom API filters for the StockLocation endpoint."""
|
||||
|
||||
location_type = rest_filters.ModelChoiceFilter(
|
||||
queryset=StockLocationType.objects.all(),
|
||||
field_name='location_type'
|
||||
)
|
||||
|
||||
has_location_type = rest_filters.BooleanFilter(label='has_location_type', method='filter_has_location_type')
|
||||
|
||||
def filter_has_location_type(self, queryset, name, value):
|
||||
"""Filter by whether or not the location has a location type"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.exclude(location_type=None)
|
||||
else:
|
||||
return queryset.filter(location_type=None)
|
||||
|
||||
|
||||
class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for list view of StockLocation objects.
|
||||
|
||||
@ -233,6 +252,7 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
||||
'tags',
|
||||
)
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
filterset_class = StockLocationFilter
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""Download the filtered queryset as a data file"""
|
||||
@ -356,6 +376,60 @@ class StockLocationTree(ListAPI):
|
||||
ordering = ['level', 'name']
|
||||
|
||||
|
||||
class StockLocationTypeList(ListCreateAPI):
|
||||
"""API endpoint for a list of StockLocationType objects.
|
||||
|
||||
- GET: Return a list of all StockLocationType objects
|
||||
- POST: Create a StockLocationType
|
||||
"""
|
||||
|
||||
queryset = StockLocationType.objects.all()
|
||||
serializer_class = StockSerializers.StockLocationTypeSerializer
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
"name",
|
||||
"location_count",
|
||||
"icon",
|
||||
]
|
||||
|
||||
ordering = [
|
||||
"-location_count",
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include location count."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class StockLocationTypeDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API detail endpoint for a StockLocationType object.
|
||||
|
||||
- GET: return a single StockLocationType
|
||||
- PUT: update a StockLocationType
|
||||
- PATCH: partial update a StockLocationType
|
||||
- DELETE: delete a StockLocationType
|
||||
"""
|
||||
|
||||
queryset = StockLocationType.objects.all()
|
||||
serializer_class = StockSerializers.StockLocationTypeSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include location count."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class StockFilter(rest_filters.FilterSet):
|
||||
"""FilterSet for StockItem LIST API."""
|
||||
|
||||
@ -1398,6 +1472,15 @@ stock_api_urls = [
|
||||
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||
])),
|
||||
|
||||
# Stock location type endpoints
|
||||
re_path(r'^location-type/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': StockLocationType}, name='api-location-type-metadata'),
|
||||
re_path(r'^.*$', StockLocationTypeDetail.as_view(), name='api-location-type-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', StockLocationTypeList.as_view(), name="api-location-type-list"),
|
||||
])),
|
||||
|
||||
# Endpoints for bulk stock adjustment actions
|
||||
re_path(r'^count/', StockCount.as_view(), name='api-stock-count'),
|
||||
re_path(r'^add/', StockAdd.as_view(), name='api-stock-add'),
|
||||
|
43
InvenTree/stock/migrations/0103_stock_location_types.py
Normal file
43
InvenTree/stock/migrations/0103_stock_location_types.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Generated by Django 3.2.20 on 2023-09-21 11:50
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0102_alter_stockitem_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StockLocationType',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
|
||||
('name', models.CharField(help_text='Name', max_length=100, verbose_name='Name')),
|
||||
('description', models.CharField(blank=True, help_text='Description (optional)', max_length=250, verbose_name='Description')),
|
||||
('icon', models.CharField(blank=True, help_text='Default icon for all locations that have no icon set (optional)', max_length=100, verbose_name='Icon')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Stock Location type',
|
||||
'verbose_name_plural': 'Stock Location types',
|
||||
},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='stocklocation',
|
||||
old_name='icon',
|
||||
new_name='custom_icon',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stocklocation',
|
||||
name='custom_icon',
|
||||
field=models.CharField(blank=True, db_column='icon', help_text='Icon (optional)', max_length=100, verbose_name='Icon'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stocklocation',
|
||||
name='location_type',
|
||||
field=models.ForeignKey(blank=True, help_text='Stock location type of this location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_locations', to='stock.stocklocationtype', verbose_name='Location type'),
|
||||
),
|
||||
]
|
@ -41,6 +41,66 @@ from plugin.events import trigger_event
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
class StockLocationType(MetadataMixin, models.Model):
|
||||
"""A type of stock location like Warehouse, room, shelf, drawer.
|
||||
|
||||
Attributes:
|
||||
name: brief name
|
||||
description: longer form description
|
||||
icon: icon class
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
|
||||
verbose_name = _("Stock Location type")
|
||||
verbose_name_plural = _("Stock Location types")
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return API url."""
|
||||
return reverse('api-location-type-list')
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a StockLocationType."""
|
||||
return self.name
|
||||
|
||||
name = models.CharField(
|
||||
blank=False,
|
||||
max_length=100,
|
||||
verbose_name=_("Name"),
|
||||
help_text=_("Name"),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
blank=True,
|
||||
max_length=250,
|
||||
verbose_name=_("Description"),
|
||||
help_text=_("Description (optional)")
|
||||
)
|
||||
|
||||
icon = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_("Icon"),
|
||||
help_text=_("Default icon for all locations that have no icon set (optional)")
|
||||
)
|
||||
|
||||
|
||||
class StockLocationManager(TreeManager):
|
||||
"""Custom database manager for the StockLocation class.
|
||||
|
||||
StockLocation querysets will automatically select related fields for performance.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Prefetch queryset to optimize db hits.
|
||||
|
||||
- Joins the StockLocationType by default for speedier icon access
|
||||
"""
|
||||
return super().get_queryset().select_related("location_type")
|
||||
|
||||
|
||||
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""Organization tree for StockItem objects.
|
||||
|
||||
@ -48,6 +108,8 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
Stock locations can be hierarchical as required
|
||||
"""
|
||||
|
||||
objects = StockLocationManager()
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties"""
|
||||
|
||||
@ -107,11 +169,12 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""Return API url."""
|
||||
return reverse('api-location-list')
|
||||
|
||||
icon = models.CharField(
|
||||
custom_icon = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_("Icon"),
|
||||
help_text=_("Icon (optional)")
|
||||
help_text=_("Icon (optional)"),
|
||||
db_column="icon",
|
||||
)
|
||||
|
||||
owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True,
|
||||
@ -133,6 +196,38 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
help_text=_('This is an external stock location')
|
||||
)
|
||||
|
||||
location_type = models.ForeignKey(
|
||||
StockLocationType,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Location type"),
|
||||
related_name="stock_locations",
|
||||
null=True, blank=True,
|
||||
help_text=_("Stock location type of this location"),
|
||||
)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Get the current icon used for this location.
|
||||
|
||||
The icon field on this model takes precedences over the possibly assigned stock location type
|
||||
"""
|
||||
if self.custom_icon:
|
||||
return self.custom_icon
|
||||
|
||||
if self.location_type:
|
||||
return self.location_type.icon
|
||||
|
||||
return ""
|
||||
|
||||
@icon.setter
|
||||
def icon(self, value):
|
||||
"""Setter to keep model API compatibility. But be careful:
|
||||
|
||||
If the field gets loaded as default value by any form which is later saved,
|
||||
the location no longer inherits its icon from the location type.
|
||||
"""
|
||||
self.custom_icon = value
|
||||
|
||||
def get_location_owner(self):
|
||||
"""Get the closest "owner" for this location.
|
||||
|
||||
|
@ -5,7 +5,7 @@ from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import BooleanField, Case, Q, Value, When
|
||||
from django.db.models import BooleanField, Case, Count, Q, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -28,7 +28,7 @@ from InvenTree.serializers import (InvenTreeCurrencySerializer,
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
StockItemTracking, StockLocation, StockLocationType)
|
||||
|
||||
|
||||
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
@ -763,6 +763,36 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
||||
StockItemTracking.objects.bulk_create(transaction_notes)
|
||||
|
||||
|
||||
class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for StockLocationType model."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
model = StockLocationType
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
"location_count",
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
"location_count",
|
||||
]
|
||||
|
||||
location_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add location count to each location type."""
|
||||
|
||||
return queryset.annotate(
|
||||
location_count=Count("stock_locations")
|
||||
)
|
||||
|
||||
|
||||
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for a simple tree view."""
|
||||
|
||||
@ -799,14 +829,17 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
'items',
|
||||
'owner',
|
||||
'icon',
|
||||
'custom_icon',
|
||||
'structural',
|
||||
'external',
|
||||
|
||||
'location_type',
|
||||
'location_type_detail',
|
||||
'tags',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'barcode_hash',
|
||||
'icon',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -844,6 +877,12 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
# explicitly set this field, so it gets included for AutoSchema
|
||||
icon = serializers.CharField(read_only=True)
|
||||
|
||||
# Detail for location type
|
||||
location_type_detail = StockLocationTypeSerializer(source="location_type", read_only=True, many=False)
|
||||
|
||||
|
||||
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
||||
"""Serializer for StockItemAttachment model."""
|
||||
|
@ -19,7 +19,8 @@ from common.models import InvenTreeSetting
|
||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from stock.models import (StockItem, StockItemTestResult, StockLocation,
|
||||
StockLocationType)
|
||||
|
||||
|
||||
class StockAPITestCase(InvenTreeAPITestCase):
|
||||
@ -94,7 +95,10 @@ class StockLocationTest(StockAPITestCase):
|
||||
'items',
|
||||
'pathstring',
|
||||
'owner',
|
||||
'url'
|
||||
'url',
|
||||
'icon',
|
||||
'location_type',
|
||||
'location_type_detail',
|
||||
]
|
||||
|
||||
response = self.get(self.list_url, expected_code=200)
|
||||
@ -293,6 +297,89 @@ class StockLocationTest(StockAPITestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
non_structural_location.full_clean()
|
||||
|
||||
def test_stock_location_icon(self):
|
||||
"""Test stock location icon inheritance from StockLocationType."""
|
||||
parent_location = StockLocation.objects.create(name="Parent location")
|
||||
|
||||
location_type = StockLocationType.objects.create(name="Box", description="This is a very cool type of box", icon="fas fa-box")
|
||||
location = StockLocation.objects.create(name="Test location", custom_icon="fas fa-microscope", location_type=location_type, parent=parent_location)
|
||||
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json()
|
||||
self.assertEqual(res[0]["icon"], "fas fa-microscope", "Custom icon from location should be returned")
|
||||
|
||||
location.custom_icon = ""
|
||||
location.save()
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json()
|
||||
self.assertEqual(res[0]["icon"], "fas fa-box", "Custom icon is None, therefore it should inherit the location type icon")
|
||||
|
||||
location_type.icon = ""
|
||||
location_type.save()
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json()
|
||||
self.assertEqual(res[0]["icon"], "", "Custom icon and location type icon is None, None should be returned")
|
||||
|
||||
def test_stock_location_list_filter(self):
|
||||
"""Test stock location list filters."""
|
||||
parent_location = StockLocation.objects.create(name="Parent location")
|
||||
|
||||
location_type = StockLocationType.objects.create(name="Box", description="This is a very cool type of box", icon="fas fa-box")
|
||||
location_type2 = StockLocationType.objects.create(name="Shelf", description="This is a very cool type of shelf", icon="fas fa-shapes")
|
||||
StockLocation.objects.create(name="Test location w. type", location_type=location_type, parent=parent_location)
|
||||
StockLocation.objects.create(name="Test location w. type 2", parent=parent_location, location_type=location_type2)
|
||||
StockLocation.objects.create(name="Test location wo type", parent=parent_location)
|
||||
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk), "has_location_type": "1"}, expected_code=200).json()
|
||||
self.assertEqual(len(res), 2)
|
||||
self.assertEqual(res[0]["name"], "Test location w. type")
|
||||
self.assertEqual(res[1]["name"], "Test location w. type 2")
|
||||
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk), "location_type": str(location_type.pk)}, expected_code=200).json()
|
||||
self.assertEqual(len(res), 1)
|
||||
self.assertEqual(res[0]["name"], "Test location w. type")
|
||||
|
||||
res = self.get(self.list_url, {"parent": str(parent_location.pk), "has_location_type": "0"}, expected_code=200).json()
|
||||
self.assertEqual(len(res), 1)
|
||||
self.assertEqual(res[0]["name"], "Test location wo type")
|
||||
|
||||
|
||||
class StockLocationTypeTest(StockAPITestCase):
|
||||
"""Tests for the StockLocationType API endpoints."""
|
||||
|
||||
list_url = reverse('api-location-type-list')
|
||||
|
||||
def test_list(self):
|
||||
"""Test that the list endpoint works as expected."""
|
||||
|
||||
location_types = [
|
||||
StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box"),
|
||||
StockLocationType.objects.create(name="Type 2", description="Type 2 desc", icon="fas fa-box"),
|
||||
StockLocationType.objects.create(name="Type 3", description="Type 3 desc", icon="fas fa-box"),
|
||||
]
|
||||
|
||||
StockLocation.objects.create(name="Loc 1", location_type=location_types[0])
|
||||
StockLocation.objects.create(name="Loc 2", location_type=location_types[0])
|
||||
StockLocation.objects.create(name="Loc 3", location_type=location_types[1])
|
||||
|
||||
res = self.get(self.list_url, expected_code=200).json()
|
||||
self.assertEqual(len(res), 3)
|
||||
self.assertCountEqual([r["location_count"] for r in res], [2, 1, 0])
|
||||
|
||||
def test_delete(self):
|
||||
"""Test that we can delete a location type via API."""
|
||||
location_type = StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box")
|
||||
self.delete(reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), expected_code=204)
|
||||
self.assertEqual(StockLocationType.objects.count(), 0)
|
||||
|
||||
def test_create(self):
|
||||
"""Test that we can create a location type via API."""
|
||||
self.post(self.list_url, {"name": "Test Type 1", "description": "Test desc 1", "icon": "fas fa-box"}, expected_code=201)
|
||||
self.assertIsNotNone(StockLocationType.objects.filter(name="Test Type 1").first())
|
||||
|
||||
def test_update(self):
|
||||
"""Test that we can update a location type via API."""
|
||||
location_type = StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box")
|
||||
res = self.patch(reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), {"icon": "fas fa-shapes"}, expected_code=200).json()
|
||||
self.assertEqual(res["icon"], "fas fa-shapes")
|
||||
|
||||
|
||||
class StockItemListTest(StockAPITestCase):
|
||||
"""Tests for the StockItem API LIST endpoint."""
|
||||
|
Reference in New Issue
Block a user