2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

Add SelectionList concept (#8054)

* Add SelectionList model, APIs and simple tests

* Add managment entries

* Add field to serializer

* add more tests for parameters

* Add support for SelectionList to CUI

* Add selection option to PUI

* fix display

* add PUI admin entries

* remove get_api_url

* fix modeldict

* Add models for meta

* Add test for inactive lists

* Add locking and testing for locking

* ignore unneeded section

* Add PUI testing for adding parameter

* Add selectionList admin

* also allow creating entries

* extend tests

* force click

* and more testing

* adapt test?

* more assurance?

* make test more robust

* more retries but shorter runs

* Update playwright.config.ts

* Add docs

* Add note regarding administration

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

* make help text more descriptive

* fix migration

* remove unneeded UI entries

* add lables and describtions to TableFields

* factor out selectionList forms

* add key to button

* cleanup imports

* add editable fields

* Add function to add row

* fix render warning

* remove dead parameter

* fix migrations

* fix migrations

* fix format

* autofix

* fix migrations

* fix create / update loop

* fix addition of empty lists

* extend tests

* adjust changelog entry

* fix updating loop

* update test name

* merge migrations

* simplify request

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

* fix test

* fix test clearing section

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2024-11-27 03:30:39 +01:00
committed by GitHub
parent 20fb1250f8
commit af39189e7e
28 changed files with 1278 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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