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:
Lukas 2023-10-11 07:34:38 +02:00 committed by GitHub
parent 6f2dca729d
commit e8e0b57cea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 548 additions and 12 deletions

View File

@ -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

View File

@ -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)

View File

@ -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'),

View 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'),
),
]

View File

@ -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.

View File

@ -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."""

View File

@ -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."""

View File

@ -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() {

View File

@ -25,4 +25,19 @@
</tbody>
</table>
<div class='panel-heading'>
<div class='d-flex flex-span'>
<h4>{% trans "Stock Location Types" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-success' id='new-location-type'>
<span class='fas fa-plus-circle'></span> {% trans "New Location Type" %}
</button>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='location-type-table'></table>
{% endblock content %}

View File

@ -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: `<span class="${data.icon} me-1"></span>${data.name}`,
},
parameters
);
}
function renderBuild(data, parameters={}) {
var image = blankImage();

View File

@ -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" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
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" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
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;
}
}
},
]
});
}

View File

@ -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" %}'
},
};
}

View File

@ -114,6 +114,7 @@ class RuleSet(models.Model):
],
'stock_location': [
'stock_stocklocation',
'stock_stocklocationtype',
'label_stocklocationlabel',
'report_stocklocationreport'
],