fix(extras): Add choice_value lookup for ChoiceSetField (#22366)

Introduce ChoiceSetField as ArrayField subclass for custom field
choices and implement choice_value lookup to filter by value element
only. Update GraphQL filter to use ExtraChoicesLookup with contains and
length options.

Fixes #22324
This commit is contained in:
Martin Hauser 2026-06-03 20:06:18 +02:00 committed by GitHub
parent 902aa495dd
commit 2e50fc3d97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 212 additions and 15 deletions

View File

@ -1,4 +1,10 @@
from django.db.models import TextField from django.contrib.postgres.fields import ArrayField
from django.db.models import CharField, TextField
__all__ = (
'CachedValueField',
'ChoiceSetField',
)
class CachedValueField(TextField): class CachedValueField(TextField):
@ -6,3 +12,18 @@ class CachedValueField(TextField):
Currently a dummy field to prevent custom lookups being applied globally to TextField. Currently a dummy field to prevent custom lookups being applied globally to TextField.
""" """
pass pass
class ChoiceSetField(ArrayField):
"""
An ArrayField of two-element [value, label] string pairs representing custom field choices.
"""
def __init__(self, **kwargs):
kwargs['base_field'] = ArrayField(base_field=CharField(max_length=100), size=2)
super().__init__(**kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
# base_field is fixed by __init__ and omitted from migrations
del kwargs['base_field']
return name, path, args, kwargs

View File

@ -0,0 +1,30 @@
import strawberry
import strawberry_django
from django.db.models import Q, QuerySet
from strawberry.directive import DirectiveValue
from strawberry.types import Info
__all__ = (
'ExtraChoicesLookup',
)
@strawberry.input(
one_of=True,
description='Lookup for extra choices defined on a choice set. Only one of the lookup fields can be set.',
)
class ExtraChoicesLookup:
contains: str | None = strawberry.field(
default=strawberry.UNSET, description='Has an extra choice with this value'
)
length: int | None = strawberry.field(
default=strawberry.UNSET, description='Number of extra choices'
)
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> tuple[QuerySet, Q]:
if self.contains is not strawberry.UNSET and self.contains is not None:
return queryset, Q(**{f'{prefix}choice_value': self.contains})
if self.length is not strawberry.UNSET and self.length is not None:
return queryset, Q(**{f'{prefix}len': self.length})
return queryset, Q()

View File

@ -23,6 +23,7 @@ if TYPE_CHECKING:
SiteFilter, SiteFilter,
SiteGroupFilter, SiteGroupFilter,
) )
from extras.graphql.filter_lookups import ExtraChoicesLookup
from netbox.graphql.enums import ColorEnum from netbox.graphql.enums import ColorEnum
from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
from tenancy.graphql.filters import TenantFilter, TenantGroupFilter from tenancy.graphql.filters import TenantFilter, TenantGroupFilter
@ -198,7 +199,7 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
) = ( ) = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( extra_choices: Annotated['ExtraChoicesLookup', strawberry.lazy('extras.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()

View File

@ -3,7 +3,16 @@ from django.contrib.postgres.fields.ranges import RangeField
from django.db.models import CharField, JSONField, Lookup from django.db.models import CharField, JSONField, Lookup
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from .fields import CachedValueField from .fields import CachedValueField, ChoiceSetField
__all__ = (
'ChoiceValueLookup',
'Empty',
'JSONEmpty',
'NetContainsOrEquals',
'NetHost',
'RangeContains',
)
class RangeContains(Lookup): class RangeContains(Lookup):
@ -34,6 +43,22 @@ class RangeContains(Lookup):
return sql, params return sql, params
class ChoiceValueLookup(Lookup):
"""
Match rows where any [value, label] pair in a ChoiceSetField has the given value.
Compares the RHS against the first element (the value) of each pair.
"""
lookup_name = 'choice_value'
prepare_rhs = False
def as_sql(self, compiler, connection):
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
# Slice the value column of the two-dimensional array and match any element
return f'{rhs} = ANY({lhs}[:][1:1])', [*rhs_params, *lhs_params]
class Empty(Lookup): class Empty(Lookup):
""" """
Filter on whether a string is empty. Filter on whether a string is empty.
@ -99,6 +124,7 @@ class NetContainsOrEquals(Lookup):
ArrayField.register_lookup(RangeContains) ArrayField.register_lookup(RangeContains)
ChoiceSetField.register_lookup(ChoiceValueLookup)
CharField.register_lookup(Empty) CharField.register_lookup(Empty)
JSONField.register_lookup(JSONEmpty) JSONField.register_lookup(JSONEmpty)
CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetHost)

View File

@ -0,0 +1,18 @@
from django.db import migrations
import extras.fields
class Migration(migrations.Migration):
dependencies = [
('extras', '0138_customfieldchoiceset_choice_colors'),
]
operations = [
migrations.AlterField(
model_name='customfieldchoiceset',
name='extra_choices',
field=extras.fields.ChoiceSetField(blank=True, null=True),
),
]

View File

@ -7,7 +7,6 @@ import django_filters
import jsonschema import jsonschema
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.db.models import F, Func, Value from django.db.models import F, Func, Value
@ -21,6 +20,7 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.data import CHOICE_SETS from extras.data import CHOICE_SETS
from extras.fields import ChoiceSetField
from netbox.context import query_cache from netbox.context import query_cache
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
@ -877,11 +877,7 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
null=True, null=True,
help_text=_('Base set of predefined choices (optional)') help_text=_('Base set of predefined choices (optional)')
) )
extra_choices = ArrayField( extra_choices = ChoiceSetField(
ArrayField(
base_field=models.CharField(max_length=100),
size=2
),
blank=True, blank=True,
null=True null=True
) )

View File

@ -325,6 +325,64 @@ class CustomFieldChoiceSetTestCase(APIViewTestCases.APIViewTestCase):
response = self.client.post(self._get_list_url(), data, format='json', **self.header) response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_graphql_filter_extra_choices(self):
"""Filter choice sets by choice value and by number of choices."""
self.add_permissions('extras.view_customfieldchoiceset')
# '1A' appears here only as a label, so it must not match contains
CustomFieldChoiceSet.objects.create(
name='Choice Set Labels',
extra_choices=[['sel1', 'Selection 1'], ['other', '1A']],
)
def run(lookup):
query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
return sorted(row['name'] for row in data['data']['custom_field_choice_set_list'])
# contains matches choice values only, never labels
self.assertEqual(run('{contains: "1A"}'), ['Choice Set 1'])
self.assertEqual(run('{contains: "sel1"}'), ['Choice Set Labels'])
self.assertEqual(run('{contains: "Selection 1"}'), [])
# length is the number of [value, label] pairs
self.assertEqual(run('{length: 2}'), ['Choice Set Labels'])
self.assertEqual(run('{length: 1}'), [])
def test_graphql_filter_extra_choices_rejects_array_operands(self):
"""The legacy flat and nested array operand shapes fail schema validation."""
self.add_permissions('extras.view_customfieldchoiceset')
def run_invalid(lookup):
query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIn('errors', response.json())
# shapes advertised or attempted before #22324
run_invalid('{contains: ["1A"]}')
run_invalid('{contains: [["1A", "Choice 1A"]]}')
def test_graphql_filter_extra_choices_via_relation(self):
"""The extra_choices lookup composes through the choice_set relation prefix."""
self.add_permissions('extras.view_customfield')
for choice_set in CustomFieldChoiceSet.objects.filter(name__in=['Choice Set 1', 'Choice Set 2']):
CustomField.objects.create(
name=f'cf_{choice_set.name[-1]}',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=choice_set,
)
query = '{ custom_field_list(filters: {choice_set: {extra_choices: {contains: "1A"}}}) { name } }'
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
self.assertEqual([row['name'] for row in data['data']['custom_field_list']], ['cf_1'])
class CustomLinkTestCase(APIViewTestCases.APIViewTestCase): class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
model = CustomLink model = CustomLink

View File

@ -0,0 +1,31 @@
from django.core.exceptions import FieldError
from django.test import TestCase
from extras.choices import CustomFieldChoiceSetBaseChoices
from extras.models import CustomFieldChoiceSet, EventRule
class ChoiceValueLookupTestCase(TestCase):
def test_choice_value_matches_values_only(self):
"""choice_value matches the value element of a pair, never the label."""
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=[['sel1', 'Selection 1'], ['other', 'sel2']],
)
self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel1').count(), 1)
self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel2').count(), 0)
def test_choice_value_excludes_null_extra_choices(self):
"""Choice sets without extra choices are excluded without raising."""
CustomFieldChoiceSet.objects.create(
name='Base Only',
base_choices=CustomFieldChoiceSetBaseChoices.IATA,
)
self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel1').count(), 0)
self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__len=2).count(), 0)
def test_choice_value_not_registered_on_plain_array_fields(self):
"""choice_value is scoped to ChoiceSetField and unavailable on other ArrayFields."""
with self.assertRaises(FieldError):
EventRule.objects.filter(event_types__choice_value='x').exists()

View File

@ -1,10 +1,15 @@
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import Deserializer # noqa: F401 from django.core.serializers.json import Deserializer
from django.core.serializers.json import Serializer as Serializer_ from django.core.serializers.json import Serializer as Serializer_
from django.utils.encoding import is_protected_type from django.utils.encoding import is_protected_type
# NOTE: Module must contain both Serializer and Deserializer # NOTE: Module must contain both Serializer and Deserializer
__all__ = (
'Deserializer',
'Serializer',
)
class Serializer(Serializer_): class Serializer(Serializer_):
""" """
@ -14,8 +19,8 @@ class Serializer(Serializer_):
def _value_from_field(self, obj, field): def _value_from_field(self, obj, field):
value = field.value_from_object(obj) value = field.value_from_object(obj)
# Handle ArrayFields of protected types # Handle ArrayFields (including subclasses) of protected types
if type(field) is ArrayField: if isinstance(field, ArrayField):
if not value or is_protected_type(value[0]): if not value or is_protected_type(value[0]):
return value return value

View File

@ -198,8 +198,8 @@ class ModelTestCase(TestCase):
model_dict[key] = [[r.lower, r.upper - 1] for r in value] model_dict[key] = [[r.lower, r.upper - 1] for r in value]
else: else:
# Convert ArrayFields to CSV strings # Convert ArrayFields (including subclasses) to CSV strings
if type(field) is ArrayField: if isinstance(field, ArrayField):
if getattr(field.base_field, 'choices', None): if getattr(field.base_field, 'choices', None):
# Values for fields with pre-defined choices can be returned as lists # Values for fields with pre-defined choices can be returned as lists
model_dict[key] = value model_dict[key] = value

View File

@ -2,7 +2,8 @@ from django.test import TestCase
from dcim.choices import SiteStatusChoices from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.models import Tag from extras.choices import CustomFieldChoiceSetBaseChoices
from extras.models import CustomFieldChoiceSet, Tag
from utilities.serialization import deserialize_object, serialize_object from utilities.serialization import deserialize_object, serialize_object
@ -32,6 +33,16 @@ class SerializationTestCase(TestCase):
self.assertEqual(data['foo'], 123) self.assertEqual(data['foo'], 123)
self.assertNotIn('description', data) self.assertNotIn('description', data)
def test_serialize_object_empty_array_field_subclass(self):
"""An empty ArrayField subclass value serializes as a list, not a string."""
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
base_choices=CustomFieldChoiceSetBaseChoices.IATA,
extra_choices=[],
)
data = serialize_object(choice_set)
self.assertEqual(data['extra_choices'], [])
def test_deserialize_object(self): def test_deserialize_object(self):
data = { data = {
'name': 'Site 1', 'name': 'Site 1',