From e8e0b57cea83f53348e01c01e2eb1383660d8f72 Mon Sep 17 00:00:00 2001 From: Lukas <76838159+wolflu05@users.noreply.github.com> Date: Wed, 11 Oct 2023 07:34:38 +0200 Subject: [PATCH] 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 --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/stock/admin.py | 21 +++- InvenTree/stock/api.py | 85 +++++++++++++++- .../migrations/0103_stock_location_types.py | 43 ++++++++ InvenTree/stock/models.py | 99 ++++++++++++++++++- InvenTree/stock/serializers.py | 45 ++++++++- InvenTree/stock/test_api.py | 91 ++++++++++++++++- .../InvenTree/settings/settings_staff_js.html | 76 ++++++++++++++ .../templates/InvenTree/settings/stock.html | 15 +++ .../js/translated/model_renderers.js | 13 +++ InvenTree/templates/js/translated/stock.js | 41 +++++++- .../templates/js/translated/table_filters.js | 24 +++++ InvenTree/users/models.py | 1 + 13 files changed, 548 insertions(+), 12 deletions(-) create mode 100644 InvenTree/stock/migrations/0103_stock_location_types.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 3ccf0ba108..0901fdc13b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 136 +INVENTREE_API_VERSION = 137 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v137 -> 2023-10-04 : https://github.com/inventree/InvenTree/pull/5588 + - Adds StockLocationType API endpoints + - Adds custom_icon, location_type to StockLocation endpoint + v136 -> 2023-09-23 : https://github.com/inventree/InvenTree/pull/5595 - Adds structural to StockLocation and PartCategory tree endpoints diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 3efb3fbf9b..43e2187df6 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -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) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index d02cc1c056..3212e5e09e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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'/', 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'), diff --git a/InvenTree/stock/migrations/0103_stock_location_types.py b/InvenTree/stock/migrations/0103_stock_location_types.py new file mode 100644 index 0000000000..ce5ca5b78c --- /dev/null +++ b/InvenTree/stock/migrations/0103_stock_location_types.py @@ -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'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 9079c76894..ce71685923 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index aaca689cc5..647f96b789 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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.""" diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 86ac9d1f87..16ef993c52 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -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.""" diff --git a/InvenTree/templates/InvenTree/settings/settings_staff_js.html b/InvenTree/templates/InvenTree/settings/settings_staff_js.html index 093dacf113..f5e89462c8 100644 --- a/InvenTree/templates/InvenTree/settings/settings_staff_js.html +++ b/InvenTree/templates/InvenTree/settings/settings_staff_js.html @@ -408,6 +408,82 @@ onPanelLoad('parts', function() { }); }); +// Javascript for the Stock settings panel +onPanelLoad("stock", function() { + + // Construct the stock location type table + $('#location-type-table').bootstrapTable({ + url: '{% url "api-location-type-list" %}', + search: true, + sortable: true, + formatNoMatches: function() { + return '{% trans "No stock location types found" %}'; + }, + columns: [ + { + field: 'name', + sortable: true, + title: '{% trans "Name" %}', + }, + { + field: 'description', + sortable: false, + title: '{% trans "Description" %}', + }, + { + field: 'icon', + sortable: true, + title: '{% trans "Icon" %}', + }, + { + field: 'location_count', + sortable: true, + title: '{% trans "Location count" %}', + formatter: function(value, row) { + let html = value; + let buttons = ''; + + buttons += makeEditButton('button-location-type-edit', row.pk, '{% trans "Edit Location Type" %}'); + buttons += makeDeleteButton('button-location-type-delete', row.pk, '{% trans "Delete Location type" %}'); + + html += wrapButtons(buttons); + return html; + } + } + ] + }); + + $('#location-type-table').on('click', '.button-location-type-edit', function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-location-type-list" %}${pk}/`, { + title: '{% trans "Edit Location Type" %}', + fields: stockLocationTypeFields(), + refreshTable: '#location-type-table', + }); + }); + + $('#location-type-table').on('click', '.button-location-type-delete', function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-location-type-list" %}${pk}/`, { + title: '{% trans "Delete Location Type" %}', + method: 'DELETE', + refreshTable: '#location-type-table', + }); + }); + + $('#new-location-type').click(function() { + // Construct a new location type + constructForm('{% url "api-location-type-list" %}', { + fields: stockLocationTypeFields(), + title: '{% trans "New Location Type" %}', + method: 'POST', + refreshTable: '#location-type-table', + }); + }); +}); + // Javascript for the Stocktake settings panel onPanelLoad('stocktake', function() { diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index b0badca6de..388118555c 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -25,4 +25,19 @@ + +
+
+

{% trans "Stock Location Types" %}

+ {% include "spacer.html" %} +
+ +
+
+
+ +
+ {% endblock content %} diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 810adf32cb..b65083867e 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -24,6 +24,7 @@ renderReturnOrder, renderStockItem, renderStockLocation, + renderStockLocationType, renderSupplierPart, renderUser, */ @@ -59,6 +60,8 @@ function getModelRenderer(model) { return renderStockItem; case 'stocklocation': return renderStockLocation; + case 'stocklocationtype': + return renderStockLocationType; case 'part': return renderPart; case 'partcategory': @@ -259,6 +262,16 @@ function renderStockLocation(data, parameters={}) { ); } +// Renderer for "StockLocationType" model +function renderStockLocationType(data, parameters={}) { + return renderModel( + { + text: `${data.name}`, + }, + parameters + ); +} + function renderBuild(data, parameters={}) { var image = blankImage(); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 38f250c189..910c20d7e1 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -81,6 +81,7 @@ serializeStockItem, stockItemFields, stockLocationFields, + stockLocationTypeFields, uninstallStockItem, */ @@ -130,6 +131,20 @@ function serializeStockItem(pk, options={}) { constructForm(url, options); } +function stockLocationTypeFields() { + const fields = { + name: {}, + description: {}, + icon: { + help_text: `{% trans "Default icon for all locations that have no icon set (optional) - Explore all available icons on" %} Font Awesome.`, + placeholder: 'fas fa-box', + icon: "fa-icons", + }, + } + + return fields; +} + function stockLocationFields(options={}) { var fields = { @@ -146,9 +161,20 @@ function stockLocationFields(options={}) { owner: {}, structural: {}, external: {}, - icon: { + location_type: { + secondary: { + title: '{% trans "Add Location type" %}', + fields: function() { + const fields = stockLocationTypeFields(); + + return fields; + } + }, + }, + custom_icon: { help_text: `{% trans "Icon (optional) - Explore all available icons on" %} Font Awesome.`, placeholder: 'fas fa-box', + icon: "fa-icons", }, }; @@ -2729,7 +2755,18 @@ function loadStockLocationTable(table, options) { formatter: function(value) { return yesNoLabel(value); } - } + }, + { + field: 'location_type', + title: '{% trans "Location type" %}', + switchable: true, + sortable: false, + formatter: function(value, row) { + if (row.location_type_detail) { + return row.location_type_detail.name; + } + } + }, ] }); } diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 65b4857f60..9fea43ff0b 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -242,6 +242,30 @@ function getStockLocationFilters() { type: 'bool', title: '{% trans "External" %}', }, + location_type: { + title: '{% trans "Location type" %}', + options: function() { + const locationTypes = {}; + + inventreeGet('{% url "api-location-type-list" %}', {}, { + async: false, + success: function(response) { + for(const locationType of response) { + locationTypes[locationType.pk] = { + key: locationType.pk, + value: locationType.name, + } + } + } + }); + + return locationTypes; + }, + }, + has_location_type: { + type: 'bool', + title: '{% trans "Has location type" %}' + }, }; } diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index e224fbc4ef..a2c8b01453 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -114,6 +114,7 @@ class RuleSet(models.Model): ], 'stock_location': [ 'stock_stocklocation', + 'stock_stocklocationtype', 'label_stocklocationlabel', 'report_stocklocationreport' ],