diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 1e7028cc0bc7..01058c1c877d 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f62cf923b4f3..2d1b4e364b2e 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index e257c31a2f2d..ca33b208b6ea 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -808,6 +808,82 @@ def get_queryset(self): 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( + '/', + include([ + # Entries + path( + 'entry/', + include([ + path( + '/', + 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 @@ def get_queryset(self): ), # Icons path('icons/', IconList.as_view(), name='api-icon-list'), + # Selection lists + path('selection/', include(selection_urls)), ] admin_api_urls = [ diff --git a/src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py b/src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py new file mode 100644 index 000000000000..3db86b80b96f --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py @@ -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', + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index fb57bfa67db1..71b918f86650 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3508,6 +3508,169 @@ def clean(self) -> None: 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.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index ed134d27079a..23c938d9af1e 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -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 diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index cdf8ae4a597d..e07afacb933b 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -29,7 +29,7 @@ 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 @@ NotificationEntry, NotificationMessage, ProjectCode, + SelectionList, + SelectionListEntry, WebhookEndpoint, WebhookMessage, ) @@ -434,7 +436,7 @@ def test_defaults(self): 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 @@ def test_validation(self): ) +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.""" diff --git a/src/backend/InvenTree/part/migrations/0132_partparametertemplate_selectionlist.py b/src/backend/InvenTree/part/migrations/0132_partparametertemplate_selectionlist.py new file mode 100644 index 000000000000..e30ffb34c9b9 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0132_partparametertemplate_selectionlist.py @@ -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', + ), + ) + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 6aab93986406..8b6ae18e1baf 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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 @@ def validate_unique(self, exclude=None): 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 @@ def get_choices(self): 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, diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 85badda77d12..66001ff93001 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -316,7 +316,16 @@ class Meta: """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, diff --git a/src/backend/InvenTree/templates/js/translated/model_renderers.js b/src/backend/InvenTree/templates/js/translated/model_renderers.js index 0b96c9a1ec1b..e66de8098912 100644 --- a/src/backend/InvenTree/templates/js/translated/model_renderers.js +++ b/src/backend/InvenTree/templates/js/translated/model_renderers.js @@ -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 + ); +} diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index a065aad924ae..8aba569efbe3 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -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: {}, }; } diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index c755702a7254..161b1c16425b 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -347,6 +347,8 @@ def get_ruleset_ignore(): 'common_webhookendpoint', 'common_webhookmessage', 'common_inventreecustomuserstatemodel', + 'common_selectionlistentry', + 'common_selectionlist', 'users_owner', # Third-party tables 'error_report_error', diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 3bdc40fcd41b..8b20b1745a4a 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -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 */ diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 8a4426564b1e..6cc1d9fe5281 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -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[]; }; diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index e979d0f7e3af..6db54e8164d4 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -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({ })} + {value.length > 0 ? ( value.map((item: any, idx: number) => { @@ -170,6 +183,26 @@ export function TableField({ )} + {definition.addRow && ( + + + + { + if (definition.addRow === undefined) return; + const ret = definition.addRow(); + if (ret) { + const val = field.value; + val.push(ret); + field.onChange(val); + } + }} + /> + + + + )} ); } diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index 1b5f412e768c..a108ef1999db 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -34,3 +34,16 @@ export function RenderImportSession({ }): ReactNode { return instance && ; } + +export function RenderSelectionList({ + instance +}: Readonly): ReactNode { + return ( + instance && ( + + ) + ); +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 8e95b604a8ba..0778809813ed 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -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 }; diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index ea802ac17337..47d358413d4b 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -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`, diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 8ca5dca0ae49..0206dcd55ff8 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -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/', diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 1c78bd3e339f..84bf7ca60bf5 100644 --- a/src/frontend/src/enums/ModelType.tsx +++ b/src/frontend/src/enums/ModelType.tsx @@ -33,5 +33,6 @@ export enum ModelType { labeltemplate = 'labeltemplate', pluginconfig = 'pluginconfig', contenttype = 'contenttype', + selectionlist = 'selectionlist', error = 'error' } diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index f3901681688c..ecff565731cc 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -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'); diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx new file mode 100644 index 000000000000..f13de381616b --- /dev/null +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -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 ( + + + + + + + + + + + + + + + props.removeFn(props.idx)} /> + + + ); +} + +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) => ( + + ), + addRow: () => { + return { + value: '', + label: '', + description: '', + active: true + }; + } + } + }; +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 2b3fe2a7ccd2..afc959fe14c9 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -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: , - content: + content: }, { name: 'category-parameters', diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx new file mode 100644 index 000000000000..5973eb1502d2 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx @@ -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 ( + + + + {t`Part Parameter Template`} + + + + + + + + {t`Selection Lists`} + + + + + + + ); +} diff --git a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx b/src/frontend/src/tables/part/PartParameterTemplateTable.tsx index b77e7c2e10b4..3a27402ddb34 100644 --- a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartParameterTemplateTable.tsx @@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() { description: {}, units: {}, choices: {}, - checkbox: {} + checkbox: {}, + selectionlist: {} }; }, []); diff --git a/src/frontend/src/tables/part/SelectionListTable.tsx b/src/frontend/src/tables/part/SelectionListTable.tsx new file mode 100644 index 000000000000..acbbe100e346 --- /dev/null +++ b/src/frontend/src/tables/part/SelectionListTable.tsx @@ -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 [ + newSelectionList.open()} + tooltip={t`Add Selection List`} + /> + ]; + }, []); + + return ( + <> + {newSelectionList.modal} + {editSelectionList.modal} + {deleteSelectionList.modal} + + + ); +} diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts new file mode 100644 index 000000000000..b7cc3166111c --- /dev/null +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -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(); +});