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:
parent
902aa495dd
commit
2e50fc3d97
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue