mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
6f2dca729d
commit
e8e0b57cea
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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() {
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
@ -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" %}'
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -114,6 +114,7 @@ class RuleSet(models.Model):
|
||||
],
|
||||
'stock_location': [
|
||||
'stock_stocklocation',
|
||||
'stock_stocklocationtype',
|
||||
'label_stocklocationlabel',
|
||||
'report_stocklocationreport'
|
||||
],
|
||||
|
Loading…
Reference in New Issue
Block a user