mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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:
		@@ -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)*) |
 | 
					| 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. |
 | 
					| 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* |
 | 
					| 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
 | 
					### 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" %}
 | 
					{% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %}
 | 
				
			||||||
{% include 'img.html' %}
 | 
					{% include 'img.html' %}
 | 
				
			||||||
{% endwith %}
 | 
					{% 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.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,13 +1,16 @@
 | 
				
			|||||||
"""InvenTree API version information."""
 | 
					"""InvenTree API version information."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# InvenTree API version
 | 
					# 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."""
 | 
					"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
INVENTREE_API_TEXT = """
 | 
					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
 | 
					v285 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8559
 | 
				
			||||||
    - Adds better description for registration endpoints
 | 
					    - Adds better description for registration endpoints
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -808,6 +808,82 @@ class IconList(ListAPI):
 | 
				
			|||||||
        return get_icon_packs().values()
 | 
					        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 = [
 | 
					settings_api_urls = [
 | 
				
			||||||
    # User settings
 | 
					    # User settings
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
@@ -1016,6 +1092,8 @@ common_api_urls = [
 | 
				
			|||||||
    ),
 | 
					    ),
 | 
				
			||||||
    # Icons
 | 
					    # Icons
 | 
				
			||||||
    path('icons/', IconList.as_view(), name='api-icon-list'),
 | 
					    path('icons/', IconList.as_view(), name='api-icon-list'),
 | 
				
			||||||
 | 
					    # Selection lists
 | 
				
			||||||
 | 
					    path('selection/', include(selection_urls)),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
admin_api_urls = [
 | 
					admin_api_urls = [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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',
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -3508,6 +3508,169 @@ class InvenTreeCustomUserStateModel(models.Model):
 | 
				
			|||||||
        return super().clean()
 | 
					        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):
 | 
					class BarcodeScanResult(InvenTree.models.InvenTreeModel):
 | 
				
			||||||
    """Model for storing barcode scans results."""
 | 
					    """Model for storing barcode scans results."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
"""JSON serializers for common components."""
 | 
					"""JSON serializers for common components."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.contenttypes.models import ContentType
 | 
					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.urls import reverse
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -638,3 +638,123 @@ class IconPackageSerializer(serializers.Serializer):
 | 
				
			|||||||
    prefix = serializers.CharField()
 | 
					    prefix = serializers.CharField()
 | 
				
			||||||
    fonts = serializers.DictField(child=serializers.CharField())
 | 
					    fonts = serializers.DictField(child=serializers.CharField())
 | 
				
			||||||
    icons = serializers.DictField(child=IconSerializer())
 | 
					    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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,7 +29,7 @@ from InvenTree.unit_test import (
 | 
				
			|||||||
    InvenTreeTestCase,
 | 
					    InvenTreeTestCase,
 | 
				
			||||||
    PluginMixin,
 | 
					    PluginMixin,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from part.models import Part
 | 
					from part.models import Part, PartParameterTemplate
 | 
				
			||||||
from plugin import registry
 | 
					from plugin import registry
 | 
				
			||||||
from plugin.models import NotificationUserSetting
 | 
					from plugin.models import NotificationUserSetting
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -45,6 +45,8 @@ from .models import (
 | 
				
			|||||||
    NotificationEntry,
 | 
					    NotificationEntry,
 | 
				
			||||||
    NotificationMessage,
 | 
					    NotificationMessage,
 | 
				
			||||||
    ProjectCode,
 | 
					    ProjectCode,
 | 
				
			||||||
 | 
					    SelectionList,
 | 
				
			||||||
 | 
					    SelectionListEntry,
 | 
				
			||||||
    WebhookEndpoint,
 | 
					    WebhookEndpoint,
 | 
				
			||||||
    WebhookMessage,
 | 
					    WebhookMessage,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -434,7 +436,7 @@ class SettingsTest(InvenTreeTestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                InvenTreeSetting.set_setting(key, value, change_user=self.user)
 | 
					                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}'")
 | 
					                print(f"test_defaults: Failed to set default value for setting '{key}'")
 | 
				
			||||||
                raise exc
 | 
					                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):
 | 
					class AdminTest(AdminTestCase):
 | 
				
			||||||
    """Tests for the admin interface integration."""
 | 
					    """Tests for the admin interface integration."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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',
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -3724,6 +3724,7 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
 | 
				
			|||||||
        description: Description of the parameter [string]
 | 
					        description: Description of the parameter [string]
 | 
				
			||||||
        checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
 | 
					        checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
 | 
				
			||||||
        choices: List of valid choices for the parameter [string]
 | 
					        choices: List of valid choices for the parameter [string]
 | 
				
			||||||
 | 
					        selectionlist: SelectionList that should be used for choices [selectionlist]
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
@@ -3805,6 +3806,9 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def get_choices(self):
 | 
					    def get_choices(self):
 | 
				
			||||||
        """Return a list of choices for this parameter template."""
 | 
					        """Return a list of choices for this parameter template."""
 | 
				
			||||||
 | 
					        if self.selectionlist:
 | 
				
			||||||
 | 
					            return self.selectionlist.get_choices()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not self.choices:
 | 
					        if not self.choices:
 | 
				
			||||||
            return []
 | 
					            return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -3845,6 +3849,16 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
 | 
				
			|||||||
        blank=True,
 | 
					        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(
 | 
					@receiver(
 | 
				
			||||||
    post_save,
 | 
					    post_save,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -316,7 +316,16 @@ class PartParameterTemplateSerializer(
 | 
				
			|||||||
        """Metaclass defining serializer fields."""
 | 
					        """Metaclass defining serializer fields."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = PartParameterTemplate
 | 
					        model = PartParameterTemplate
 | 
				
			||||||
        fields = ['pk', 'name', 'units', 'description', 'parts', 'checkbox', 'choices']
 | 
					        fields = [
 | 
				
			||||||
 | 
					            'pk',
 | 
				
			||||||
 | 
					            'name',
 | 
				
			||||||
 | 
					            'units',
 | 
				
			||||||
 | 
					            'description',
 | 
				
			||||||
 | 
					            'parts',
 | 
				
			||||||
 | 
					            'checkbox',
 | 
				
			||||||
 | 
					            'choices',
 | 
				
			||||||
 | 
					            'selectionlist',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    parts = serializers.IntegerField(
 | 
					    parts = serializers.IntegerField(
 | 
				
			||||||
        read_only=True,
 | 
					        read_only=True,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -102,6 +102,8 @@ function getModelRenderer(model) {
 | 
				
			|||||||
        return renderReportTemplate;
 | 
					        return renderReportTemplate;
 | 
				
			||||||
    case 'pluginconfig':
 | 
					    case 'pluginconfig':
 | 
				
			||||||
        return renderPluginConfig;
 | 
					        return renderPluginConfig;
 | 
				
			||||||
 | 
					    case 'selectionlist':
 | 
				
			||||||
 | 
					        return renderSelectionList;
 | 
				
			||||||
    default:
 | 
					    default:
 | 
				
			||||||
        // Un-handled model type
 | 
					        // Un-handled model type
 | 
				
			||||||
        console.error(`Rendering not implemented for model '${model}'`);
 | 
					        console.error(`Rendering not implemented for model '${model}'`);
 | 
				
			||||||
@@ -589,3 +591,15 @@ function renderPluginConfig(data, parameters={}) {
 | 
				
			|||||||
        parameters
 | 
					        parameters
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Render for "SelectionList" model
 | 
				
			||||||
 | 
					function renderSelectionList(data, parameters={}) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return renderModel(
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            text: data.name,
 | 
				
			||||||
 | 
					            textSecondary: data.description,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        parameters
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1356,6 +1356,19 @@ function partParameterFields(options={}) {
 | 
				
			|||||||
                                        display_name: choice,
 | 
					                                        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',
 | 
					            icon: 'fa-th-list',
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        checkbox: {},
 | 
					        checkbox: {},
 | 
				
			||||||
 | 
					        selectionlist: {},
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -347,6 +347,8 @@ class RuleSet(models.Model):
 | 
				
			|||||||
            'common_webhookendpoint',
 | 
					            'common_webhookendpoint',
 | 
				
			||||||
            'common_webhookmessage',
 | 
					            'common_webhookmessage',
 | 
				
			||||||
            'common_inventreecustomuserstatemodel',
 | 
					            'common_inventreecustomuserstatemodel',
 | 
				
			||||||
 | 
					            'common_selectionlistentry',
 | 
				
			||||||
 | 
					            'common_selectionlist',
 | 
				
			||||||
            'users_owner',
 | 
					            'users_owner',
 | 
				
			||||||
            # Third-party tables
 | 
					            # Third-party tables
 | 
				
			||||||
            'error_report_error',
 | 
					            'error_report_error',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,8 +5,8 @@ export default defineConfig({
 | 
				
			|||||||
  fullyParallel: true,
 | 
					  fullyParallel: true,
 | 
				
			||||||
  timeout: 90000,
 | 
					  timeout: 90000,
 | 
				
			||||||
  forbidOnly: !!process.env.CI,
 | 
					  forbidOnly: !!process.env.CI,
 | 
				
			||||||
  retries: process.env.CI ? 1 : 0,
 | 
					  retries: process.env.CI ? 2 : 0,
 | 
				
			||||||
  workers: process.env.CI ? 2 : undefined,
 | 
					  workers: process.env.CI ? 3 : undefined,
 | 
				
			||||||
  reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
 | 
					  reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /* Configure projects for major browsers */
 | 
					  /* Configure projects for major browsers */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,6 +49,7 @@ export type ApiFormAdjustFilterType = {
 | 
				
			|||||||
 * @param onValueChange : Callback function to call when the field value changes
 | 
					 * @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 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 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
 | 
					 * @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export type ApiFormFieldType = {
 | 
					export type ApiFormFieldType = {
 | 
				
			||||||
@@ -94,6 +95,7 @@ export type ApiFormFieldType = {
 | 
				
			|||||||
  adjustValue?: (value: any) => any;
 | 
					  adjustValue?: (value: any) => any;
 | 
				
			||||||
  onValueChange?: (value: any, record?: any) => void;
 | 
					  onValueChange?: (value: any, record?: any) => void;
 | 
				
			||||||
  adjustFilters?: (value: ApiFormAdjustFilterType) => any;
 | 
					  adjustFilters?: (value: ApiFormAdjustFilterType) => any;
 | 
				
			||||||
 | 
					  addRow?: () => any;
 | 
				
			||||||
  headers?: string[];
 | 
					  headers?: string[];
 | 
				
			||||||
  depends_on?: string[];
 | 
					  depends_on?: string[];
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@ import type { FieldValues, UseControllerReturn } from 'react-hook-form';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { identifierString } from '../../../functions/conversion';
 | 
					import { identifierString } from '../../../functions/conversion';
 | 
				
			||||||
import { InvenTreeIcon } from '../../../functions/icons';
 | 
					import { InvenTreeIcon } from '../../../functions/icons';
 | 
				
			||||||
 | 
					import { AddItemButton } from '../../buttons/AddItemButton';
 | 
				
			||||||
import { StandaloneField } from '../StandaloneField';
 | 
					import { StandaloneField } from '../StandaloneField';
 | 
				
			||||||
import type { ApiFormFieldType } from './ApiFormField';
 | 
					import type { ApiFormFieldType } from './ApiFormField';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -109,6 +110,17 @@ export function TableField({
 | 
				
			|||||||
    field.onChange(val);
 | 
					    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
 | 
					  // Extract errors associated with the current row
 | 
				
			||||||
  const rowErrors: any = useCallback(
 | 
					  const rowErrors: any = useCallback(
 | 
				
			||||||
    (idx: number) => {
 | 
					    (idx: number) => {
 | 
				
			||||||
@@ -134,6 +146,7 @@ export function TableField({
 | 
				
			|||||||
          })}
 | 
					          })}
 | 
				
			||||||
        </Table.Tr>
 | 
					        </Table.Tr>
 | 
				
			||||||
      </Table.Thead>
 | 
					      </Table.Thead>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Table.Tbody>
 | 
					      <Table.Tbody>
 | 
				
			||||||
        {value.length > 0 ? (
 | 
					        {value.length > 0 ? (
 | 
				
			||||||
          value.map((item: any, idx: number) => {
 | 
					          value.map((item: any, idx: number) => {
 | 
				
			||||||
@@ -170,6 +183,26 @@ export function TableField({
 | 
				
			|||||||
          </Table.Tr>
 | 
					          </Table.Tr>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
      </Table.Tbody>
 | 
					      </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>
 | 
					    </Table>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,3 +34,16 @@ export function RenderImportSession({
 | 
				
			|||||||
}): ReactNode {
 | 
					}): ReactNode {
 | 
				
			||||||
  return instance && <RenderInlineModel primary={instance.data_file} />;
 | 
					  return instance && <RenderInlineModel primary={instance.data_file} />;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function RenderSelectionList({
 | 
				
			||||||
 | 
					  instance
 | 
				
			||||||
 | 
					}: Readonly<InstanceRenderInterface>): ReactNode {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    instance && (
 | 
				
			||||||
 | 
					      <RenderInlineModel
 | 
				
			||||||
 | 
					        primary={instance.name}
 | 
				
			||||||
 | 
					        secondary={instance.description}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,8 @@ import {
 | 
				
			|||||||
  RenderContentType,
 | 
					  RenderContentType,
 | 
				
			||||||
  RenderError,
 | 
					  RenderError,
 | 
				
			||||||
  RenderImportSession,
 | 
					  RenderImportSession,
 | 
				
			||||||
  RenderProjectCode
 | 
					  RenderProjectCode,
 | 
				
			||||||
 | 
					  RenderSelectionList
 | 
				
			||||||
} from './Generic';
 | 
					} from './Generic';
 | 
				
			||||||
import { ModelInformationDict } from './ModelType';
 | 
					import { ModelInformationDict } from './ModelType';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@@ -94,6 +95,7 @@ const RendererLookup: EnumDictionary<
 | 
				
			|||||||
  [ModelType.labeltemplate]: RenderLabelTemplate,
 | 
					  [ModelType.labeltemplate]: RenderLabelTemplate,
 | 
				
			||||||
  [ModelType.pluginconfig]: RenderPlugin,
 | 
					  [ModelType.pluginconfig]: RenderPlugin,
 | 
				
			||||||
  [ModelType.contenttype]: RenderContentType,
 | 
					  [ModelType.contenttype]: RenderContentType,
 | 
				
			||||||
 | 
					  [ModelType.selectionlist]: RenderSelectionList,
 | 
				
			||||||
  [ModelType.error]: RenderError
 | 
					  [ModelType.error]: RenderError
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -286,6 +286,12 @@ export const ModelInformationDict: ModelDict = {
 | 
				
			|||||||
    api_endpoint: ApiEndpoints.content_type_list,
 | 
					    api_endpoint: ApiEndpoints.content_type_list,
 | 
				
			||||||
    icon: 'list_details'
 | 
					    icon: 'list_details'
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  selectionlist: {
 | 
				
			||||||
 | 
					    label: () => t`Selection List`,
 | 
				
			||||||
 | 
					    label_multiple: () => t`Selection Lists`,
 | 
				
			||||||
 | 
					    api_endpoint: ApiEndpoints.selectionlist_list,
 | 
				
			||||||
 | 
					    icon: 'list_details'
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  error: {
 | 
					  error: {
 | 
				
			||||||
    label: () => t`Error`,
 | 
					    label: () => t`Error`,
 | 
				
			||||||
    label_multiple: () => t`Errors`,
 | 
					    label_multiple: () => t`Errors`,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,6 +49,8 @@ export enum ApiEndpoints {
 | 
				
			|||||||
  owner_list = 'user/owner/',
 | 
					  owner_list = 'user/owner/',
 | 
				
			||||||
  content_type_list = 'contenttype/',
 | 
					  content_type_list = 'contenttype/',
 | 
				
			||||||
  icons = 'icons/',
 | 
					  icons = 'icons/',
 | 
				
			||||||
 | 
					  selectionlist_list = 'selection/',
 | 
				
			||||||
 | 
					  selectionlist_detail = 'selection/:id/',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Barcode API endpoints
 | 
					  // Barcode API endpoints
 | 
				
			||||||
  barcode = 'barcode/',
 | 
					  barcode = 'barcode/',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,5 +33,6 @@ export enum ModelType {
 | 
				
			|||||||
  labeltemplate = 'labeltemplate',
 | 
					  labeltemplate = 'labeltemplate',
 | 
				
			||||||
  pluginconfig = 'pluginconfig',
 | 
					  pluginconfig = 'pluginconfig',
 | 
				
			||||||
  contenttype = 'contenttype',
 | 
					  contenttype = 'contenttype',
 | 
				
			||||||
 | 
					  selectionlist = 'selectionlist',
 | 
				
			||||||
  error = 'error'
 | 
					  error = 'error'
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,10 @@ import { t } from '@lingui/macro';
 | 
				
			|||||||
import { IconPackages } from '@tabler/icons-react';
 | 
					import { IconPackages } from '@tabler/icons-react';
 | 
				
			||||||
import { useMemo, useState } from 'react';
 | 
					import { useMemo, useState } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { api } from '../App';
 | 
				
			||||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
 | 
					import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
 | 
				
			||||||
 | 
					import { ApiEndpoints } from '../enums/ApiEndpoints';
 | 
				
			||||||
 | 
					import { apiUrl } from '../states/ApiState';
 | 
				
			||||||
import { useGlobalSettingsState } from '../states/SettingsState';
 | 
					import { useGlobalSettingsState } from '../states/SettingsState';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@@ -204,7 +207,7 @@ export function usePartParameterFields({
 | 
				
			|||||||
              setChoices(
 | 
					              setChoices(
 | 
				
			||||||
                _choices.map((choice) => {
 | 
					                _choices.map((choice) => {
 | 
				
			||||||
                  return {
 | 
					                  return {
 | 
				
			||||||
                    label: choice.trim(),
 | 
					                    display_name: choice.trim(),
 | 
				
			||||||
                    value: choice.trim()
 | 
					                    value: choice.trim()
 | 
				
			||||||
                  };
 | 
					                  };
 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
@@ -214,6 +217,22 @@ export function usePartParameterFields({
 | 
				
			|||||||
              setChoices([]);
 | 
					              setChoices([]);
 | 
				
			||||||
              setFieldType('string');
 | 
					              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 {
 | 
					          } else {
 | 
				
			||||||
            setChoices([]);
 | 
					            setChoices([]);
 | 
				
			||||||
            setFieldType('string');
 | 
					            setFieldType('string');
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										117
									
								
								src/frontend/src/forms/selectionListFields.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/frontend/src/forms/selectionListFields.tsx
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -66,6 +66,8 @@ const MachineManagementPanel = Loadable(
 | 
				
			|||||||
  lazy(() => import('./MachineManagementPanel'))
 | 
					  lazy(() => import('./MachineManagementPanel'))
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ErrorReportTable = Loadable(
 | 
					const ErrorReportTable = Loadable(
 | 
				
			||||||
  lazy(() => import('../../../../tables/settings/ErrorTable'))
 | 
					  lazy(() => import('../../../../tables/settings/ErrorTable'))
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
@@ -86,6 +88,10 @@ const CustomStateTable = Loadable(
 | 
				
			|||||||
  lazy(() => import('../../../../tables/settings/CustomStateTable'))
 | 
					  lazy(() => import('../../../../tables/settings/CustomStateTable'))
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const CustomUnitsTable = Loadable(
 | 
				
			||||||
 | 
					  lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const PartParameterTemplateTable = Loadable(
 | 
					const PartParameterTemplateTable = Loadable(
 | 
				
			||||||
  lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
 | 
					  lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
@@ -169,7 +175,7 @@ export default function AdminCenter() {
 | 
				
			|||||||
        name: 'part-parameters',
 | 
					        name: 'part-parameters',
 | 
				
			||||||
        label: t`Part Parameters`,
 | 
					        label: t`Part Parameters`,
 | 
				
			||||||
        icon: <IconList />,
 | 
					        icon: <IconList />,
 | 
				
			||||||
        content: <PartParameterTemplateTable />
 | 
					        content: <PartParameterPanel />
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        name: 'category-parameters',
 | 
					        name: 'category-parameters',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() {
 | 
				
			|||||||
      description: {},
 | 
					      description: {},
 | 
				
			||||||
      units: {},
 | 
					      units: {},
 | 
				
			||||||
      choices: {},
 | 
					      choices: {},
 | 
				
			||||||
      checkbox: {}
 | 
					      checkbox: {},
 | 
				
			||||||
 | 
					      selectionlist: {}
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										134
									
								
								src/frontend/src/tables/part/SelectionListTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/frontend/src/tables/part/SelectionListTable.tsx
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										100
									
								
								src/frontend/tests/settings/selectionList.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/frontend/tests/settings/selectionList.spec.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
		Reference in New Issue
	
	Block a user