2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-01 21:16:46 +00:00

Add SelectionList concept (#8054)

* Add SelectionList model, APIs and simple tests

* Add managment entries

* Add field to serializer

* add more tests for parameters

* Add support for SelectionList to CUI

* Add selection option to PUI

* fix display

* add PUI admin entries

* remove get_api_url

* fix modeldict

* Add models for meta

* Add test for inactive lists

* Add locking and testing for locking

* ignore unneeded section

* Add PUI testing for adding parameter

* Add selectionList admin

* also allow creating entries

* extend tests

* force click

* and more testing

* adapt test?

* more assurance?

* make test more robust

* more retries but shorter runs

* Update playwright.config.ts

* Add docs

* Add note regarding administration

* Adapt to https://github.com/inventree/InvenTree/pull/8093

* make help text more descriptive

* fix migration

* remove unneeded UI entries

* add lables and describtions to TableFields

* factor out selectionList forms

* add key to button

* cleanup imports

* add editable fields

* Add function to add row

* fix render warning

* remove dead parameter

* fix migrations

* fix migrations

* fix format

* autofix

* fix migrations

* fix create / update loop

* fix addition of empty lists

* extend tests

* adjust changelog entry

* fix updating loop

* update test name

* merge migrations

* simplify request

* - Add entry count to list
- Move parameter table to default accordion

* fix test

* fix test clearing section

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair 2024-11-27 03:30:39 +01:00 committed by GitHub
parent 20fb1250f8
commit af39189e7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1278 additions and 11 deletions

View File

@ -26,6 +26,7 @@ Parameter templates are used to define the different types of parameters which a
| Units | Optional units field (*must be a valid [physical unit](#parameter-units)*) |
| Choices | A comma-separated list of valid choices for parameter values linked to this template. |
| Checkbox | If set, parameters linked to this template can only be assigned values *true* or *false* |
| Selection List | If set, parameters linked to this template can only be assigned values from the linked [selection list](#selection-lists) |
### Create Template
@ -105,3 +106,12 @@ Parameter sorting takes unit conversion into account, meaning that values provid
{% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %}
{% include 'img.html' %}
{% endwith %}
### Selection Lists
Selection Lists can be used to add a large number of predefined values to a parameter template. This can be useful for parameters which must be selected from a large predefined list of values (e.g. a list of standardised colo codes). Choices on templates are limited to 5000 characters, selection lists can be used to overcome this limitation.
It is possible that plugins lock selection lists to ensure a known state.
Administration of lists can be done through the Part Parameter section in the Admin Center or via the API.

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 285
INVENTREE_API_VERSION = 286
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v286 - 2024-11-26 : https://github.com/inventree/InvenTree/pull/8054
- Adds "SelectionList" and "SelectionListEntry" API endpoints
v285 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8559
- Adds better description for registration endpoints

View File

@ -808,6 +808,82 @@ class IconList(ListAPI):
return get_icon_packs().values()
class SelectionListList(ListCreateAPI):
"""List view for SelectionList objects."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class SelectionListDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionList object."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated]
class EntryMixin:
"""Mixin for SelectionEntry views."""
queryset = common.models.SelectionListEntry.objects.all()
serializer_class = common.serializers.SelectionEntrySerializer
permission_classes = [permissions.IsAuthenticated]
lookup_url_kwarg = 'entrypk'
def get_queryset(self):
"""Prefetch related fields."""
pk = self.kwargs.get('pk', None)
queryset = super().get_queryset().filter(list=pk)
queryset = queryset.prefetch_related('list')
return queryset
class SelectionEntryList(EntryMixin, ListCreateAPI):
"""List view for SelectionEntry objects."""
class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionEntry object."""
selection_urls = [
path(
'<int:pk>/',
include([
# Entries
path(
'entry/',
include([
path(
'<int:entrypk>/',
include([
path(
'',
SelectionEntryDetail.as_view(),
name='api-selectionlistentry-detail',
)
]),
),
path(
'',
SelectionEntryList.as_view(),
name='api-selectionlistentry-list',
),
]),
),
path('', SelectionListDetail.as_view(), name='api-selectionlist-detail'),
]),
),
path('', SelectionListList.as_view(), name='api-selectionlist-list'),
]
# API URL patterns
settings_api_urls = [
# User settings
path(
@ -1016,6 +1092,8 @@ common_api_urls = [
),
# Icons
path('icons/', IconList.as_view(), name='api-icon-list'),
# Selection lists
path('selection/', include(selection_urls)),
]
admin_api_urls = [

View File

@ -0,0 +1,191 @@
# Generated by Django 4.2.16 on 2024-11-24 12:41
import django.db.models.deletion
from django.db import migrations, models
import InvenTree.models
class Migration(migrations.Migration):
dependencies = [
('plugin', '0009_alter_pluginconfig_key'),
('common', '0031_auto_20241026_0024'),
]
operations = [
migrations.CreateModel(
name='SelectionList',
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 of the selection list',
max_length=100,
unique=True,
verbose_name='Name',
),
),
(
'description',
models.CharField(
blank=True,
help_text='Description of the selection list',
max_length=250,
verbose_name='Description',
),
),
(
'locked',
models.BooleanField(
default=False,
help_text='Is this selection list locked?',
verbose_name='Locked',
),
),
(
'active',
models.BooleanField(
default=True,
help_text='Can this selection list be used?',
verbose_name='Active',
),
),
(
'source_string',
models.CharField(
blank=True,
help_text='Optional string identifying the source used for this list',
max_length=1000,
verbose_name='Source String',
),
),
(
'created',
models.DateTimeField(
auto_now_add=True,
help_text='Date and time that the selection list was created',
verbose_name='Created',
),
),
(
'last_updated',
models.DateTimeField(
auto_now=True,
help_text='Date and time that the selection list was last updated',
verbose_name='Last Updated',
),
),
],
options={
'verbose_name': 'Selection List',
'verbose_name_plural': 'Selection Lists',
},
bases=(InvenTree.models.PluginValidationMixin, models.Model),
),
migrations.CreateModel(
name='SelectionListEntry',
fields=[
(
'id',
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'value',
models.CharField(
help_text='Value of the selection list entry',
max_length=255,
verbose_name='Value',
),
),
(
'label',
models.CharField(
help_text='Label for the selection list entry',
max_length=255,
verbose_name='Label',
),
),
(
'description',
models.CharField(
blank=True,
help_text='Description of the selection list entry',
max_length=250,
verbose_name='Description',
),
),
(
'active',
models.BooleanField(
default=True,
help_text='Is this selection list entry active?',
verbose_name='Active',
),
),
(
'list',
models.ForeignKey(
blank=True,
help_text='Selection list to which this entry belongs',
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='entries',
to='common.selectionlist',
verbose_name='Selection List',
),
),
],
options={
'verbose_name': 'Selection List Entry',
'verbose_name_plural': 'Selection List Entries',
'unique_together': {('list', 'value')},
},
),
migrations.AddField(
model_name='selectionlist',
name='default',
field=models.ForeignKey(
blank=True,
help_text='Default entry for this selection list',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='common.selectionlistentry',
verbose_name='Default Entry',
),
),
migrations.AddField(
model_name='selectionlist',
name='source_plugin',
field=models.ForeignKey(
blank=True,
help_text='Plugin which provides the selection list',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='plugin.pluginconfig',
verbose_name='Source Plugin',
),
),
]

View File

@ -3508,6 +3508,169 @@ class InvenTreeCustomUserStateModel(models.Model):
return super().clean()
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Class which represents a list of selectable items for parameters.
A lists selection options can be either manually defined, or sourced from a plugin.
Attributes:
name: The name of the selection list
description: A description of the selection list
locked: Is this selection list locked (i.e. cannot be modified)?
active: Is this selection list active?
source_plugin: The plugin which provides the selection list
source_string: The string representation of the selection list
default: The default value for the selection list
created: The date/time that the selection list was created
last_updated: The date/time that the selection list was last updated
"""
class Meta:
"""Meta options for SelectionList."""
verbose_name = _('Selection List')
verbose_name_plural = _('Selection Lists')
name = models.CharField(
max_length=100,
verbose_name=_('Name'),
help_text=_('Name of the selection list'),
unique=True,
)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_('Description of the selection list'),
blank=True,
)
locked = models.BooleanField(
default=False,
verbose_name=_('Locked'),
help_text=_('Is this selection list locked?'),
)
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('Can this selection list be used?'),
)
source_plugin = models.ForeignKey(
'plugin.PluginConfig',
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('Source Plugin'),
help_text=_('Plugin which provides the selection list'),
)
source_string = models.CharField(
max_length=1000,
verbose_name=_('Source String'),
help_text=_('Optional string identifying the source used for this list'),
blank=True,
)
default = models.ForeignKey(
'SelectionListEntry',
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('Default Entry'),
help_text=_('Default entry for this selection list'),
)
created = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Created'),
help_text=_('Date and time that the selection list was created'),
)
last_updated = models.DateTimeField(
auto_now=True,
verbose_name=_('Last Updated'),
help_text=_('Date and time that the selection list was last updated'),
)
def __str__(self):
"""Return string representation of the selection list."""
if not self.active:
return f'{self.name} (Inactive)'
return self.name
@staticmethod
def get_api_url():
"""Return the API URL associated with the SelectionList model."""
return reverse('api-selectionlist-list')
def get_choices(self):
"""Return the choices for the selection list."""
choices = self.entries.filter(active=True)
return [c.value for c in choices]
class SelectionListEntry(models.Model):
"""Class which represents a single entry in a SelectionList.
Attributes:
list: The SelectionList to which this entry belongs
value: The value of the selection list entry
label: The label for the selection list entry
description: A description of the selection list entry
active: Is this selection list entry active?
"""
class Meta:
"""Meta options for SelectionListEntry."""
verbose_name = _('Selection List Entry')
verbose_name_plural = _('Selection List Entries')
unique_together = [['list', 'value']]
list = models.ForeignKey(
SelectionList,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='entries',
verbose_name=_('Selection List'),
help_text=_('Selection list to which this entry belongs'),
)
value = models.CharField(
max_length=255,
verbose_name=_('Value'),
help_text=_('Value of the selection list entry'),
)
label = models.CharField(
max_length=255,
verbose_name=_('Label'),
help_text=_('Label for the selection list entry'),
)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_('Description of the selection list entry'),
blank=True,
)
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('Is this selection list entry active?'),
)
def __str__(self):
"""Return string representation of the selection list entry."""
if not self.active:
return f'{self.label} (Inactive)'
return self.label
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
"""Model for storing barcode scans results."""

View File

@ -1,7 +1,7 @@
"""JSON serializers for common components."""
from django.contrib.contenttypes.models import ContentType
from django.db.models import OuterRef, Subquery
from django.db.models import Count, OuterRef, Subquery
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -638,3 +638,123 @@ class IconPackageSerializer(serializers.Serializer):
prefix = serializers.CharField()
fonts = serializers.DictField(child=serializers.CharField())
icons = serializers.DictField(child=IconSerializer())
class SelectionEntrySerializer(InvenTreeModelSerializer):
"""Serializer for a selection entry."""
class Meta:
"""Meta options for SelectionEntrySerializer."""
model = common_models.SelectionListEntry
fields = '__all__'
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)
if self.instance and self.instance.list.locked:
raise serializers.ValidationError({'list': _('Selection list is locked')})
return ret
class SelectionListSerializer(InvenTreeModelSerializer):
"""Serializer for a selection list."""
_choices_validated: dict = {}
class Meta:
"""Meta options for SelectionListSerializer."""
model = common_models.SelectionList
fields = [
'pk',
'name',
'description',
'active',
'locked',
'source_plugin',
'source_string',
'default',
'created',
'last_updated',
'choices',
'entry_count',
]
default = SelectionEntrySerializer(read_only=True, many=False)
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
entry_count = serializers.IntegerField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add count of entries for each selection list."""
return queryset.annotate(entry_count=Count('entries'))
def is_valid(self, *, raise_exception=False):
"""Validate the selection list. Choices are validated separately."""
choices = (
self.initial_data.pop('choices')
if self.initial_data.get('choices') is not None
else []
)
# Validate the choices
_choices_validated = []
db_entries = (
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
)
for choice in choices:
current_inst = db_entries.get(choice.get('id'))
serializer = SelectionEntrySerializer(
instance=current_inst,
data={'list': current_inst.list.pk if current_inst else None, **choice},
)
serializer.is_valid(raise_exception=raise_exception)
_choices_validated.append({
**serializer.validated_data,
'id': choice.get('id'),
})
self._choices_validated = _choices_validated
return super().is_valid(raise_exception=raise_exception)
def create(self, validated_data):
"""Create a new selection list. Save the choices separately."""
list_entry = common_models.SelectionList.objects.create(**validated_data)
for choice_data in self._choices_validated:
common_models.SelectionListEntry.objects.create(**{
**choice_data,
'list': list_entry,
})
return list_entry
def update(self, instance, validated_data):
"""Update an existing selection list. Save the choices separately."""
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
exsising_ids = {a.get('id') for a in self._choices_validated}
# Perform creations and updates.
ret = []
for data in self._choices_validated:
list_inst = data.get('list', None)
inst = inst_mapping.get(data.get('id'))
if inst is None:
if list_inst is None:
data['list'] = instance
ret.append(SelectionEntrySerializer().create(data))
else:
ret.append(SelectionEntrySerializer().update(inst, data))
# Perform deletions.
for entry_id in inst_mapping.keys() - exsising_ids:
inst_mapping[entry_id].delete()
return super().update(instance, validated_data)
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)
if self.instance and self.instance.locked:
raise serializers.ValidationError({'locked': _('Selection list is locked')})
return ret

View File

@ -29,7 +29,7 @@ from InvenTree.unit_test import (
InvenTreeTestCase,
PluginMixin,
)
from part.models import Part
from part.models import Part, PartParameterTemplate
from plugin import registry
from plugin.models import NotificationUserSetting
@ -45,6 +45,8 @@ from .models import (
NotificationEntry,
NotificationMessage,
ProjectCode,
SelectionList,
SelectionListEntry,
WebhookEndpoint,
WebhookMessage,
)
@ -434,7 +436,7 @@ class SettingsTest(InvenTreeTestCase):
try:
InvenTreeSetting.set_setting(key, value, change_user=self.user)
except Exception as exc:
except Exception as exc: # pragma: no cover
print(f"test_defaults: Failed to set default value for setting '{key}'")
raise exc
@ -1683,6 +1685,161 @@ class CustomStatusTest(TestCase):
)
class SelectionListTest(InvenTreeAPITestCase):
"""Tests for the SelectionList and SelectionListEntry model and API endpoints."""
fixtures = ['category', 'part', 'location', 'params', 'test_templates']
def setUp(self):
"""Setup for all tests."""
super().setUp()
self.list = SelectionList.objects.create(name='Test List')
self.entry1 = SelectionListEntry.objects.create(
list=self.list,
value='test1',
label='Test Entry',
description='Test Description',
)
self.entry2 = SelectionListEntry.objects.create(
list=self.list,
value='test2',
label='Test Entry 2',
description='Test Description 2',
active=False,
)
self.list2 = SelectionList.objects.create(name='Test List 2', active=False)
# Urls
self.list_url = reverse('api-selectionlist-detail', kwargs={'pk': self.list.pk})
self.entry_url = reverse(
'api-selectionlistentry-detail',
kwargs={'entrypk': self.entry1.pk, 'pk': self.list.pk},
)
def test_api(self):
"""Test the SelectionList and SelctionListEntry API endpoints."""
url = reverse('api-selectionlist-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 2)
response = self.get(self.list_url, expected_code=200)
self.assertEqual(response.data['name'], 'Test List')
self.assertEqual(len(response.data['choices']), 2)
self.assertEqual(response.data['choices'][0]['value'], 'test1')
self.assertEqual(response.data['choices'][0]['label'], 'Test Entry')
response = self.get(self.entry_url, expected_code=200)
self.assertEqual(response.data['value'], 'test1')
self.assertEqual(response.data['label'], 'Test Entry')
self.assertEqual(response.data['description'], 'Test Description')
def test_api_update(self):
"""Test adding and editing via the SelectionList."""
# Test adding a new list via the API
response = self.post(
reverse('api-selectionlist-list'),
{
'name': 'New List',
'active': True,
'choices': [{'value': '1', 'label': 'Test Entry'}],
},
expected_code=201,
)
list_pk = response.data['pk']
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '1')
# Test editing the list choices via the API (remove and add in same call)
response = self.patch(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'value': '2', 'label': 'New Label'}]},
expected_code=200,
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
entry_id = response.data['choices'][0]['id']
# Test changing an entry via list API
response = self.patch(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
expected_code=200,
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
def test_api_locked(self):
"""Test editing with locked/unlocked list."""
# Lock list
self.list.locked = True
self.list.save()
response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=400)
self.assertIn('Selection list is locked', response.data['list'])
response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=400)
self.assertIn('Selection list is locked', response.data['locked'])
# Unlock the list
self.list.locked = False
self.list.save()
response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=200)
self.assertEqual(response.data['label'], 'New Label')
response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=200)
self.assertEqual(response.data['name'], 'New Name')
def test_model_meta(self):
"""Test model meta functions."""
# Models str
self.assertEqual(str(self.list), 'Test List')
self.assertEqual(str(self.list2), 'Test List 2 (Inactive)')
self.assertEqual(str(self.entry1), 'Test Entry')
self.assertEqual(str(self.entry2), 'Test Entry 2 (Inactive)')
# API urls
self.assertEqual(self.list.get_api_url(), '/api/selection/')
def test_parameter(self):
"""Test the SelectionList parameter."""
self.assertEqual(self.list.get_choices(), ['test1'])
self.user.is_superuser = True
self.user.save()
# Add to parameter
part = Part.objects.get(pk=1)
template = PartParameterTemplate.objects.create(
name='test_parameter', units='', selectionlist=self.list
)
rsp = self.get(
reverse('api-part-parameter-template-detail', kwargs={'pk': template.pk})
)
self.assertEqual(rsp.data['name'], 'test_parameter')
self.assertEqual(rsp.data['choices'], '')
# Add to part
url = reverse('api-part-parameter-list')
response = self.post(
url,
{'part': part.pk, 'template': template.pk, 'data': 70},
expected_code=400,
)
self.assertIn('Invalid choice for parameter value', response.data['data'])
response = self.post(
url,
{'part': part.pk, 'template': template.pk, 'data': self.entry1.value},
expected_code=201,
)
self.assertEqual(response.data['data'], self.entry1.value)
class AdminTest(AdminTestCase):
"""Tests for the admin interface integration."""

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.16 on 2024-11-24 12:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0032_selectionlist_selectionlistentry_and_more'),
('part', '0131_partrelated_note'),
]
operations = [
migrations.AddField(
model_name='partparametertemplate',
name='selectionlist',
field=models.ForeignKey(
blank=True,
help_text='Selection list for this parameter',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='parameter_templates',
to='common.selectionlist',
verbose_name='Selection List',
),
)
]

View File

@ -3724,6 +3724,7 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
description: Description of the parameter [string]
checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
choices: List of valid choices for the parameter [string]
selectionlist: SelectionList that should be used for choices [selectionlist]
"""
class Meta:
@ -3805,6 +3806,9 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
def get_choices(self):
"""Return a list of choices for this parameter template."""
if self.selectionlist:
return self.selectionlist.get_choices()
if not self.choices:
return []
@ -3845,6 +3849,16 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
blank=True,
)
selectionlist = models.ForeignKey(
common.models.SelectionList,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='parameter_templates',
verbose_name=_('Selection List'),
help_text=_('Selection list for this parameter'),
)
@receiver(
post_save,

View File

@ -316,7 +316,16 @@ class PartParameterTemplateSerializer(
"""Metaclass defining serializer fields."""
model = PartParameterTemplate
fields = ['pk', 'name', 'units', 'description', 'parts', 'checkbox', 'choices']
fields = [
'pk',
'name',
'units',
'description',
'parts',
'checkbox',
'choices',
'selectionlist',
]
parts = serializers.IntegerField(
read_only=True,

View File

@ -102,6 +102,8 @@ function getModelRenderer(model) {
return renderReportTemplate;
case 'pluginconfig':
return renderPluginConfig;
case 'selectionlist':
return renderSelectionList;
default:
// Un-handled model type
console.error(`Rendering not implemented for model '${model}'`);
@ -589,3 +591,15 @@ function renderPluginConfig(data, parameters={}) {
parameters
);
}
// Render for "SelectionList" model
function renderSelectionList(data, parameters={}) {
return renderModel(
{
text: data.name,
textSecondary: data.description,
},
parameters
);
}

View File

@ -1356,6 +1356,19 @@ function partParameterFields(options={}) {
display_name: choice,
});
});
} else if (response.selectionlist) {
// Selection list - get choices from the API
inventreeGet(`{% url "api-selectionlist-list" %}${response.selectionlist}/`, {}, {
async: false,
success: function(data) {
data.choices.forEach(function(item) {
choices.push({
value: item.value,
display_name: item.label,
});
});
}
});
}
}
});
@ -1576,6 +1589,7 @@ function partParameterTemplateFields() {
icon: 'fa-th-list',
},
checkbox: {},
selectionlist: {},
};
}

View File

@ -347,6 +347,8 @@ class RuleSet(models.Model):
'common_webhookendpoint',
'common_webhookmessage',
'common_inventreecustomuserstatemodel',
'common_selectionlistentry',
'common_selectionlist',
'users_owner',
# Third-party tables
'error_report_error',

View File

@ -5,8 +5,8 @@ export default defineConfig({
fullyParallel: true,
timeout: 90000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 3 : undefined,
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
/* Configure projects for major browsers */

View File

@ -49,6 +49,7 @@ export type ApiFormAdjustFilterType = {
* @param onValueChange : Callback function to call when the field value changes
* @param adjustFilters : Callback function to adjust the filters for a related field before a query is made
* @param adjustValue : Callback function to adjust the value of the field before it is sent to the API
* @param addRow : Callback function to add a new row to a table field
* @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
*/
export type ApiFormFieldType = {
@ -94,6 +95,7 @@ export type ApiFormFieldType = {
adjustValue?: (value: any) => any;
onValueChange?: (value: any, record?: any) => void;
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
addRow?: () => any;
headers?: string[];
depends_on?: string[];
};

View File

@ -6,6 +6,7 @@ import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import { identifierString } from '../../../functions/conversion';
import { InvenTreeIcon } from '../../../functions/icons';
import { AddItemButton } from '../../buttons/AddItemButton';
import { StandaloneField } from '../StandaloneField';
import type { ApiFormFieldType } from './ApiFormField';
@ -109,6 +110,17 @@ export function TableField({
field.onChange(val);
};
const fieldDefinition = useMemo(() => {
return {
...definition,
modelRenderer: undefined,
onValueChange: undefined,
adjustFilters: undefined,
read_only: undefined,
addRow: undefined
};
}, [definition]);
// Extract errors associated with the current row
const rowErrors: any = useCallback(
(idx: number) => {
@ -134,6 +146,7 @@ export function TableField({
})}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{value.length > 0 ? (
value.map((item: any, idx: number) => {
@ -170,6 +183,26 @@ export function TableField({
</Table.Tr>
)}
</Table.Tbody>
{definition.addRow && (
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={definition.headers?.length}>
<AddItemButton
tooltip={t`Add new row`}
onClick={() => {
if (definition.addRow === undefined) return;
const ret = definition.addRow();
if (ret) {
const val = field.value;
val.push(ret);
field.onChange(val);
}
}}
/>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
)}
</Table>
);
}

View File

@ -34,3 +34,16 @@ export function RenderImportSession({
}): ReactNode {
return instance && <RenderInlineModel primary={instance.data_file} />;
}
export function RenderSelectionList({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return (
instance && (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
)
);
}

View File

@ -20,7 +20,8 @@ import {
RenderContentType,
RenderError,
RenderImportSession,
RenderProjectCode
RenderProjectCode,
RenderSelectionList
} from './Generic';
import { ModelInformationDict } from './ModelType';
import {
@ -94,6 +95,7 @@ const RendererLookup: EnumDictionary<
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.error]: RenderError
};

View File

@ -286,6 +286,12 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.content_type_list,
icon: 'list_details'
},
selectionlist: {
label: () => t`Selection List`,
label_multiple: () => t`Selection Lists`,
api_endpoint: ApiEndpoints.selectionlist_list,
icon: 'list_details'
},
error: {
label: () => t`Error`,
label_multiple: () => t`Errors`,

View File

@ -49,6 +49,8 @@ export enum ApiEndpoints {
owner_list = 'user/owner/',
content_type_list = 'contenttype/',
icons = 'icons/',
selectionlist_list = 'selection/',
selectionlist_detail = 'selection/:id/',
// Barcode API endpoints
barcode = 'barcode/',

View File

@ -33,5 +33,6 @@ export enum ModelType {
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig',
contenttype = 'contenttype',
selectionlist = 'selectionlist',
error = 'error'
}

View File

@ -2,7 +2,10 @@ import { t } from '@lingui/macro';
import { IconPackages } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { api } from '../App';
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
@ -204,7 +207,7 @@ export function usePartParameterFields({
setChoices(
_choices.map((choice) => {
return {
label: choice.trim(),
display_name: choice.trim(),
value: choice.trim()
};
})
@ -214,6 +217,22 @@ export function usePartParameterFields({
setChoices([]);
setFieldType('string');
}
} else if (record?.selectionlist) {
api
.get(
apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist)
)
.then((res) => {
setChoices(
res.data.choices.map((item: any) => {
return {
value: item.value,
display_name: item.label
};
})
);
setFieldType('choice');
});
} else {
setChoices([]);
setFieldType('string');

View File

@ -0,0 +1,117 @@
import { t } from '@lingui/macro';
import { Table } from '@mantine/core';
import { useMemo } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type {
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
function BuildAllocateLineRow({
props
}: Readonly<{
props: TableFieldRowProps;
}>) {
const valueField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'value',
required: true,
value: props.item.value,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'value', value);
}
};
}, [props]);
const labelField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'label',
required: true,
value: props.item.label,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'label', value);
}
};
}, [props]);
const descriptionField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'description',
required: true,
value: props.item.description,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'description', value);
}
};
}, [props]);
const activeField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'boolean',
name: 'active',
required: true,
value: props.item.active,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'active', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${props.item.pk}`}>
<Table.Td>
<StandaloneField fieldName='value' fieldDefinition={valueField} />
</Table.Td>
<Table.Td>
<StandaloneField fieldName='label' fieldDefinition={labelField} />
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='description'
fieldDefinition={descriptionField}
/>
</Table.Td>
<Table.Td>
<StandaloneField fieldName='active' fieldDefinition={activeField} />
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
);
}
export function selectionListFields(): ApiFormFieldSet {
return {
name: {},
description: {},
active: {},
locked: {},
source_plugin: {},
source_string: {},
choices: {
label: t`Entries`,
description: t`List of entries to choose from`,
field_type: 'table',
value: [],
headers: [t`Value`, t`Label`, t`Description`, t`Active`],
modelRenderer: (row: TableFieldRowProps) => (
<BuildAllocateLineRow props={row} />
),
addRow: () => {
return {
value: '',
label: '',
description: '',
active: true
};
}
}
};
}

View File

@ -66,6 +66,8 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
@ -86,6 +88,10 @@ const CustomStateTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomStateTable'))
);
const CustomUnitsTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
);
const PartParameterTemplateTable = Loadable(
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
);
@ -169,7 +175,7 @@ export default function AdminCenter() {
name: 'part-parameters',
label: t`Part Parameters`,
icon: <IconList />,
content: <PartParameterTemplateTable />
content: <PartParameterPanel />
},
{
name: 'category-parameters',

View File

@ -0,0 +1,29 @@
import { t } from '@lingui/macro';
import { Accordion } from '@mantine/core';
import { StylishText } from '../../../../components/items/StylishText';
import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable';
import SelectionListTable from '../../../../tables/part/SelectionListTable';
export default function PartParameterPanel() {
return (
<Accordion defaultValue='parametertemplate'>
<Accordion.Item value='parametertemplate' key='parametertemplate'>
<Accordion.Control>
<StylishText size='lg'>{t`Part Parameter Template`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PartParameterTemplateTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='selectionlist' key='selectionlist'>
<Accordion.Control>
<StylishText size='lg'>{t`Selection Lists`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SelectionListTable />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() {
description: {},
units: {},
choices: {},
checkbox: {}
checkbox: {},
selectionlist: {}
};
}, []);

View File

@ -0,0 +1,134 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { selectionListFields } from '../../forms/selectionListFields';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
/**
* Table for displaying list of selectionlist items
*/
export default function SelectionListTable() {
const table = useTable('selectionlist');
const user = useUserState();
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
sortable: true
},
{
accessor: 'description',
sortable: true
},
BooleanColumn({
accessor: 'active'
}),
BooleanColumn({
accessor: 'locked'
}),
{
accessor: 'source_plugin',
sortable: true
},
{
accessor: 'source_string',
sortable: true
},
{
accessor: 'entry_count'
}
];
}, []);
const newSelectionList = useCreateApiFormModal({
url: ApiEndpoints.selectionlist_list,
title: t`Add Selection List`,
fields: selectionListFields(),
table: table
});
const [selectedSelectionList, setSelectedSelectionList] = useState<
number | undefined
>(undefined);
const editSelectionList = useEditApiFormModal({
url: ApiEndpoints.selectionlist_list,
pk: selectedSelectionList,
title: t`Edit Selection List`,
fields: selectionListFields(),
table: table
});
const deleteSelectionList = useDeleteApiFormModal({
url: ApiEndpoints.selectionlist_list,
pk: selectedSelectionList,
title: t`Delete Selection List`,
table: table
});
const rowActions = useCallback(
(record: any): RowAction[] => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.admin),
onClick: () => {
setSelectedSelectionList(record.pk);
editSelectionList.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.admin),
onClick: () => {
setSelectedSelectionList(record.pk);
deleteSelectionList.open();
}
})
];
},
[user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add-selection-list'
onClick={() => newSelectionList.open()}
tooltip={t`Add Selection List`}
/>
];
}, []);
return (
<>
{newSelectionList.modal}
{editSelectionList.modal}
{deleteSelectionList.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.selectionlist_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
enableDownload: true
}}
/>
</>
);
}

View File

@ -0,0 +1,100 @@
import { test } from '../baseFixtures';
import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login';
test('PUI - Admin - Parameter', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
// clean old data if exists
await page
.getByRole('cell', { name: 'some list' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'some list' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// clean old data if exists
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.waitForLoadState('networkidle');
await page
.getByRole('cell', { name: 'my custom parameter' })
.waitFor({ timeout: 200 })
.then(async (cell) => {
await page
.getByRole('cell', { name: 'my custom parameter' })
.locator('..')
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
})
.catch(() => {});
// Add selection list
await page.getByRole('button', { name: 'Selection Lists' }).click();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-selection-').waitFor();
await page.getByLabel('action-button-add-selection-').click();
await page.getByLabel('text-field-name').fill('some list');
await page.getByLabel('text-field-description').fill('Listdescription');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'some list' }).waitFor();
await page.waitForTimeout(200);
// Add parameter
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.getByLabel('text-field-name').fill('my custom parameter');
await page.getByLabel('text-field-description').fill('description');
await page
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByRole('option', { name: 'some list' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('cell', { name: 'my custom parameter' }).click();
// Fill parameter
await page.goto(`${baseUrl}/part/104/parameters/`);
await page.getByLabel('Parameters').getByText('Parameters').waitFor();
await page.waitForLoadState('networkidle');
await page.getByLabel('action-button-add-parameter').waitFor();
await page.getByLabel('action-button-add-parameter').click();
await page.waitForTimeout(200);
await page.getByText('New Part Parameter').waitFor();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.nth(2)
.click();
await page
.getByText('Template *Parameter')
.locator('div')
.filter({ hasText: /^Search\.\.\.$/ })
.locator('input')
.fill('my custom parameter');
await page.getByRole('option', { name: 'my custom parameter' }).click();
await page.getByLabel('choice-field-data').fill('2');
await page.getByRole('button', { name: 'Submit' }).click();
});