Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fc6ca62
Feat: added dynamic filtering of custom_object type_fields
ifoughal Jan 12, 2026
23fec19
rollback
ifoughal Jan 12, 2026
7de2e99
Fix: set required to false for get_filterform_field
ifoughal Jan 12, 2026
4d3396f
Feat: added extra_kwargs to build call
ifoughal Jan 12, 2026
5caa6c8
rollback: added search back
ifoughal Jan 12, 2026
103fce7
style: removed trailing space
ifoughal Jan 12, 2026
acf6172
Feat: set decimal_places to 4
ifoughal Jan 12, 2026
d0dad5b
feat: set lambda extra_kwargs to f.choices to match netbox.extras.cho…
ifoughal Jan 12, 2026
25deee4
Feat: added related_object_type validation
ifoughal Jan 12, 2026
71067f2
Doc: removed irrelevent comment
ifoughal Jan 12, 2026
5da2558
Merge branch 'netboxlabs:main' into 296-custom_type_fields_filter
ifoughal Jan 15, 2026
76f8358
style: added missing blank line
ifoughal Jan 15, 2026
3d04899
style: line collapse
ifoughal Jan 15, 2026
e6c4123
Merge branch 'netboxlabs:main' into 296-custom_type_fields_filter
ifoughal Mar 19, 2026
52b958e
Merge branch 'netboxlabs:main' into 296-custom_type_fields_filter
ifoughal Mar 23, 2026
0fb1310
feat: simplified FIELD_TYPE_FILTERS null testing
ifoughalOX Mar 23, 2026
4fb6fbf
fix merge conflicts
arthanson Mar 27, 2026
eaf195f
merge main
arthanson Apr 16, 2026
ab30893
cleanup
arthanson Apr 16, 2026
5171c25
cleanup
arthanson Apr 16, 2026
61f62b1
fix
arthanson Apr 16, 2026
583352b
add filter fields to ui, update tests
arthanson Apr 16, 2026
1cb2c22
don't use netbox base classes for tests
arthanson Apr 16, 2026
513f89f
don't use netbox base classes for tests
arthanson Apr 16, 2026
374469b
don't use netbox base classes for tests
arthanson Apr 16, 2026
61bd329
don't use netbox base classes for tests
arthanson Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ def get_filterform_field(self, field, **kwargs):


class LongTextFieldType(FieldType):
def get_filterform_field(self, field, **kwargs):
return forms.CharField(
label=field,
required=False,
)

def get_model_field(self, field, **kwargs):
field_kwargs = self._safe_kwargs(**kwargs)
field_kwargs.update({"default": field.default, "unique": field.unique})
Expand Down Expand Up @@ -221,7 +227,17 @@ def get_form_field(self, field, **kwargs):
required=field.required,
initial=field.default,
max_digits=12,
decimal_places=4,
decimal_places=2,
min_value=field.validation_minimum,
max_value=field.validation_maximum,
)

def get_filterform_field(self, field, **kwargs):
return forms.DecimalField(
label=field,
required=False,
max_digits=12,
decimal_places=2,
min_value=field.validation_minimum,
max_value=field.validation_maximum,
)
Expand All @@ -245,6 +261,18 @@ def get_form_field(self, field, **kwargs):
widget=forms.Select(choices=choices),
)

def get_filterform_field(self, field, **kwargs):
choices = (
(None, '---------'),
(True, _('True')),
(False, _('False')),
)
return forms.NullBooleanField(
label=field,
required=False,
widget=forms.Select(choices=choices),
)

def get_table_column_field(self, field, **kwargs):
return BooleanColumn()

Expand All @@ -260,6 +288,13 @@ def get_form_field(self, field, **kwargs):
required=field.required, initial=field.default, widget=DatePicker()
)

def get_filterform_field(self, field, **kwargs):
return forms.DateField(
label=field,
required=False,
widget=DatePicker(),
)


class DateTimeFieldType(FieldType):
def get_model_field(self, field, **kwargs):
Expand All @@ -272,6 +307,13 @@ def get_form_field(self, field, **kwargs):
required=field.required, initial=field.default, widget=DateTimePicker()
)

def get_filterform_field(self, field, **kwargs):
return forms.DateTimeField(
label=field,
required=False,
widget=DateTimePicker(),
)


class URLFieldType(FieldType):
def get_model_field(self, field, **kwargs):
Expand All @@ -284,6 +326,12 @@ def get_form_field(self, field, **kwargs):
assume_scheme="https", required=field.required, initial=field.default
)

def get_filterform_field(self, field, **kwargs):
return forms.CharField(
label=field,
required=False,
)


class JSONFieldType(FieldType):
def get_model_field(self, field, **kwargs):
Expand All @@ -299,6 +347,17 @@ def get_form_field(self, field, **kwargs):


class SelectFieldType(FieldType):
def get_filterform_field(self, field, **kwargs):
choices = add_blank_choice(field.choice_set.choices)
return DynamicChoiceField(
choices=choices,
label=field,
required=False,
widget=APISelect(
api_url=f'/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/'
),
)

def get_model_field(self, field, **kwargs):
field_kwargs = self._safe_kwargs(**kwargs)
field_kwargs.update({"default": field.default, "unique": field.unique})
Expand Down Expand Up @@ -341,6 +400,17 @@ def get_form_field(self, field, for_csv_import=False, **kwargs):


class MultiSelectFieldType(FieldType):
def get_filterform_field(self, field, **kwargs):
choices = add_blank_choice(field.choice_set.choices)
return DynamicMultipleChoiceField(
choices=choices,
label=field,
required=False,
widget=APISelectMultiple(
api_url=f'/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/'
),
)

def get_display_value(self, instance, field_name):
return ", ".join(getattr(instance, field_name) or [])

Expand Down Expand Up @@ -504,6 +574,11 @@ def get_filterform_field(self, field, **kwargs):
required=False,
label=field,
selector=model._meta.app_label != APP_LABEL,
query_params=(
field.related_object_filter
if hasattr(field, "related_object_filter")
else None
),
)

def render_table_column(self, value):
Expand Down Expand Up @@ -825,6 +900,11 @@ def get_filterform_field(self, field, **kwargs):
required=False,
label=field,
selector=model._meta.app_label != APP_LABEL,
query_params=(
field.related_object_filter
if hasattr(field, "related_object_filter")
else None
),
)

def get_display_value(self, instance, field_name):
Expand Down
161 changes: 122 additions & 39 deletions netbox_custom_objects/filtersets.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import django_filters
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from django.contrib.postgres.fields import ArrayField
from django.db.models import JSONField, Q
from typing import Any, Dict, Optional, Type

from django.db.models import JSONField, QuerySet, Q
from django.utils.dateparse import parse_date, parse_datetime

from extras.choices import CustomFieldTypeChoices
Expand All @@ -10,11 +12,89 @@
from .models import CustomObjectType

__all__ = (
"ArrayContainsFilter",
"CustomObjectTypeFilterSet",
"get_filterset_class",
)


class ArrayContainsFilter(django_filters.MultipleChoiceFilter):
"""
Filter for ArrayField (TYPE_MULTISELECT): checks if the array contains any
of the selected values using OR semantics with PostgreSQL array containment.

Standard MultipleChoiceFilter uses ``exact`` lookup, which compares the
entire array rather than checking membership. This class uses
``__contains=[v]`` instead, matching Django's array containment operator.
"""

def filter(self, qs, value):
if not value:
return qs
q = Q()
for v in value:
q |= Q(**{f"{self.field_name}__contains": [v]})
return qs.filter(q).distinct()


@dataclass
class FilterSpec:
"""
Declarative specification describing how a custom field type
should be translated into a django-filter Filter instance.
"""
filter_class: Type[django_filters.Filter]
lookup_expr: Optional[str] = None
extra_kwargs: Optional[Dict[str, Any]] = None

def build(
self, field_name: str, label: str, queryset: Optional[QuerySet] = None, **kwargs
) -> django_filters.Filter:
"""
Instantiate and return a django-filter Filter.
Allows overriding defaults via **kwargs.
"""
filter_kwargs = {
"field_name": field_name,
"label": label,
}

if self.lookup_expr:
filter_kwargs["lookup_expr"] = self.lookup_expr

if queryset is not None:
filter_kwargs["queryset"] = queryset

# Callers (build_filter_for_field) resolve extra_kwargs callables and pass
# the results here as **kwargs; merge them directly.
filter_kwargs.update(kwargs)

return self.filter_class(**filter_kwargs)


FIELD_TYPE_FILTERS = {
CustomFieldTypeChoices.TYPE_TEXT: FilterSpec(django_filters.CharFilter, lookup_expr="icontains"),
CustomFieldTypeChoices.TYPE_LONGTEXT: FilterSpec(django_filters.CharFilter, lookup_expr="icontains"),
CustomFieldTypeChoices.TYPE_INTEGER: FilterSpec(django_filters.NumberFilter, lookup_expr="exact"),
CustomFieldTypeChoices.TYPE_DECIMAL: FilterSpec(django_filters.NumberFilter, lookup_expr="exact"),
CustomFieldTypeChoices.TYPE_BOOLEAN: FilterSpec(django_filters.BooleanFilter),
CustomFieldTypeChoices.TYPE_DATE: FilterSpec(django_filters.DateFilter, lookup_expr="exact"),
CustomFieldTypeChoices.TYPE_DATETIME: FilterSpec(django_filters.DateTimeFilter, lookup_expr="exact"),
CustomFieldTypeChoices.TYPE_URL: FilterSpec(django_filters.CharFilter, lookup_expr="icontains"),
CustomFieldTypeChoices.TYPE_JSON: FilterSpec(django_filters.CharFilter, lookup_expr="icontains"),
CustomFieldTypeChoices.TYPE_SELECT: FilterSpec(
django_filters.ChoiceFilter,
extra_kwargs={"choices": lambda f: f.choices}
),
CustomFieldTypeChoices.TYPE_MULTISELECT: FilterSpec(
ArrayContainsFilter,
extra_kwargs={"choices": lambda f: f.choices}
),
CustomFieldTypeChoices.TYPE_OBJECT: FilterSpec(django_filters.ModelChoiceFilter),
CustomFieldTypeChoices.TYPE_MULTIOBJECT: FilterSpec(django_filters.ModelMultipleChoiceFilter),
}


class CustomObjectTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = CustomObjectType
Expand All @@ -25,10 +105,44 @@ class Meta:
)


def build_filter_for_field(field) -> Optional[django_filters.Filter]:
if not (spec := FIELD_TYPE_FILTERS.get(field.type)):
return None

queryset = None
if field.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
):
related_object_type = getattr(field, "related_object_type", None)
if not related_object_type:
# Defensive guard: if data integrity is compromised and the related object type
# is missing, skip building a filter for this field rather than raising.
return None
model_class = related_object_type.model_class()
if model_class is None:
# ContentType exists but the model is no longer installed (e.g. stale content type).
return None
queryset = model_class.objects.all()

extra_kwargs = {}
if spec.extra_kwargs:
for key, value in spec.extra_kwargs.items():
extra_kwargs[key] = value(field) if callable(value) else value

return spec.build(
field_name=field.name,
label=field.label,
queryset=queryset,
**extra_kwargs,
)


def get_filterset_class(model):
"""
Create and return a filterset class for the given custom object model.
"""
# Get standard fields from the model
fields = [field.name for field in model._meta.fields]

meta = type(
Expand All @@ -37,21 +151,13 @@ def get_filterset_class(model):
{
"model": model,
"fields": fields,
# TODO: overrides should come from FieldType
# These are placeholders; should use different logic
"filter_overrides": {
JSONField: {
"filter_class": django_filters.CharFilter,
"extra": lambda f: {
"lookup_expr": "icontains",
},
},
ArrayField: {
"filter_class": django_filters.CharFilter,
"extra": lambda f: {
"lookup_expr": "icontains",
},
},
},
},
)
Expand Down Expand Up @@ -96,38 +202,15 @@ def search(self, queryset, name, value):

attrs = {
"Meta": meta,
"__module__": "database.filtersets",
"__module__": "netbox_custom_objects.filtersets",
"search": search,
}

# Add filters for M2M (multiobject) fields, which are not in model._meta.fields.
# By the time get_filterset_class() is called (at request time), after_model_generation()
# will have already resolved m2m_field.remote_field.model and .through to actual model
# classes. Calling this during app startup (before model generation) would fail.
for m2m_field in model._meta.many_to_many:
field_name = m2m_field.name
through_model = m2m_field.remote_field.through
related_model = m2m_field.remote_field.model

def make_m2m_filter(through, fname):
def filter_m2m(self, queryset, name, value):
if not value:
return queryset
ids = [v.pk for v in value]
source_ids = through.objects.filter(
target_id__in=ids
).values_list("source_id", flat=True)
return queryset.filter(pk__in=source_ids)
filter_m2m.__name__ = f"filter_{fname}"
return filter_m2m

method_name = f"filter_{field_name}"
attrs[method_name] = make_m2m_filter(through_model, field_name)
attrs[field_name] = django_filters.ModelMultipleChoiceFilter(
queryset=related_model.objects.all(),
method=method_name,
label=field_name,
)
# For each custom field, add a corresponding filter
for field in model.custom_object_type.fields.all():
filter_instance = build_filter_for_field(field)
if filter_instance:
attrs[field.name] = filter_instance

return type(
f"{model._meta.object_name}FilterSet",
Expand Down
Loading
Loading