From fc6ca6269bbcee1cd78cf2ac81db13b2c2cd1bd3 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 13:53:58 +0000 Subject: [PATCH 01/13] Feat: added dynamic filtering of custom_object type_fields --- netbox_custom_objects/field_types.py | 36 +++++++- netbox_custom_objects/filtersets.py | 118 ++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 251ac3d9..88b61b39 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -226,6 +226,15 @@ def get_form_field(self, field, **kwargs): 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, + ) class BooleanFieldType(FieldType): def get_model_field(self, field, **kwargs): @@ -491,7 +500,19 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): ) def get_filterform_field(self, field, **kwargs): - return None + """ + Returns a filter form field for object relationships. + """ + return DynamicModelChoiceField( + queryset=field.related_object_type.model_class().objects.all(), + required=field.required, + # Remove initial=field.default to allow Django to handle instance data properly + query_params=( + field.related_object_filter + if hasattr(field, "related_object_filter") + else None + ), + ) def render_table_column(self, value): return linkify(value) @@ -799,7 +820,18 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): ) def get_filterform_field(self, field, **kwargs): - return None + """ + Returns a filter form field for multi-object relationships. + """ + return DynamicModelMultipleChoiceField( + queryset=field.related_object_type.model_class().objects.all(), + required=field.required, + query_params=( + field.related_object_filter + if hasattr(field, "related_object_filter") + else None + ), + ) def get_display_value(self, instance, field_name): field = getattr(instance, field_name) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index f54efdfe..59056d0a 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -1,6 +1,9 @@ import django_filters +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type + from django.contrib.postgres.fields import ArrayField -from django.db.models import JSONField, Q +from django.db.models import JSONField, QuerySet from extras.choices import CustomFieldTypeChoices from netbox.filtersets import NetBoxModelFilterSet @@ -13,6 +16,65 @@ ) +@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 + + # Apply defaults from the spec + if self.extra_kwargs: + filter_kwargs.update(self.extra_kwargs) + + # Apply dynamic overrides (e.g. resolved choices) + 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.get_choices()} + ), + CustomFieldTypeChoices.TYPE_MULTISELECT: FilterSpec( + django_filters.MultipleChoiceFilter, + extra_kwargs={"choices": lambda f: f.get_choices()} + ), + CustomFieldTypeChoices.TYPE_OBJECT: FilterSpec(django_filters.ModelChoiceFilter), + CustomFieldTypeChoices.TYPE_MULTIOBJECT: FilterSpec(django_filters.ModelMultipleChoiceFilter), +} + + class CustomObjectTypeFilterSet(NetBoxModelFilterSet): class Meta: model = CustomObjectType @@ -22,10 +84,35 @@ class Meta: ) +def build_filter_for_field(field) -> Optional[django_filters.Filter]: + spec = FIELD_TYPE_FILTERS.get(field.type) + if not spec: + return None + + queryset = None + if field.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ): + queryset = field.related_object_type.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, + ) + + 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( @@ -34,8 +121,6 @@ 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, @@ -53,28 +138,19 @@ def get_filterset_class(model): }, ) - def search(self, queryset, name, value): - if not value.strip(): - return queryset - q = Q() - for field in model.custom_object_type.fields.all(): - if field.type in [ - CustomFieldTypeChoices.TYPE_TEXT, - CustomFieldTypeChoices.TYPE_LONGTEXT, - CustomFieldTypeChoices.TYPE_JSON, - CustomFieldTypeChoices.TYPE_URL, - ]: - q |= Q(**{f"{field.name}__icontains": value}) - return queryset.filter(q) - attrs = { "Meta": meta, - "__module__": "database.filtersets", - "search": search, + "__module__": "netbox_custom_objects.filtersets", } + # 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", - (NetBoxModelFilterSet,), + f"{model.__name__}FilterSet", + (django_filters.FilterSet,), attrs, ) From 23fec1900e1d8b4deffd29aeca15321f613d1fe3 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 14:16:18 +0000 Subject: [PATCH 02/13] rollback --- netbox_custom_objects/filtersets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 59056d0a..a3d9d11d 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -150,7 +150,7 @@ def get_filterset_class(model): attrs[field.name] = filter_instance return type( - f"{model.__name__}FilterSet", - (django_filters.FilterSet,), + f"{model._meta.object_name}FilterSet", + (NetBoxModelFilterSet,), attrs, ) From 7de2e990298d21e9005994a6078427c7cb3e51f7 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 14:29:19 +0000 Subject: [PATCH 03/13] Fix: set required to false for get_filterform_field --- netbox_custom_objects/field_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 88b61b39..55154a11 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -505,7 +505,7 @@ def get_filterform_field(self, field, **kwargs): """ return DynamicModelChoiceField( queryset=field.related_object_type.model_class().objects.all(), - required=field.required, + required=False, # Remove initial=field.default to allow Django to handle instance data properly query_params=( field.related_object_filter @@ -825,7 +825,7 @@ def get_filterform_field(self, field, **kwargs): """ return DynamicModelMultipleChoiceField( queryset=field.related_object_type.model_class().objects.all(), - required=field.required, + required=False, query_params=( field.related_object_filter if hasattr(field, "related_object_filter") From 4d3396f4203aadab2de20860424aa74965d3c8b0 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 14:29:47 +0000 Subject: [PATCH 04/13] Feat: added extra_kwargs to build call --- netbox_custom_objects/filtersets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index a3d9d11d..a83b0197 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -105,6 +105,7 @@ def build_filter_for_field(field) -> Optional[django_filters.Filter]: field_name=field.name, label=field.label, queryset=queryset, + **extra_kwargs, ) From 5caa6c87dbe0bd511e2d2d263ed73f42e1574fc4 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 14:44:54 +0000 Subject: [PATCH 05/13] rollback: added search back --- netbox_custom_objects/filtersets.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index a83b0197..2f8c527e 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -3,7 +3,7 @@ from typing import Any, Dict, Optional, Type from django.contrib.postgres.fields import ArrayField -from django.db.models import JSONField, QuerySet +from django.db.models import JSONField, QuerySet, Q from extras.choices import CustomFieldTypeChoices from netbox.filtersets import NetBoxModelFilterSet @@ -138,10 +138,25 @@ def get_filterset_class(model): }, }, ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + q = Q() + for field in model.custom_object_type.fields.all(): + if field.type in [ + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + CustomFieldTypeChoices.TYPE_JSON, + CustomFieldTypeChoices.TYPE_URL, + ]: + q |= Q(**{f"{field.name}__icontains": value}) + return queryset.filter(q) attrs = { "Meta": meta, "__module__": "netbox_custom_objects.filtersets", + "search": search, } # For each custom field, add a corresponding filter From 103fce7893c7097c541a809b2a726814d88d5908 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 15:05:11 +0000 Subject: [PATCH 06/13] style: removed trailing space --- netbox_custom_objects/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 2f8c527e..c335d823 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -138,7 +138,7 @@ def get_filterset_class(model): }, }, ) - + def search(self, queryset, name, value): if not value.strip(): return queryset From acf61720b041ff3fc75f7cd6d277bcc0e2acb04a Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 15:06:30 +0000 Subject: [PATCH 07/13] Feat: set decimal_places to 4 --- netbox_custom_objects/field_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 55154a11..79a1fb41 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -212,7 +212,7 @@ def get_model_field(self, field, **kwargs): null=True, blank=True, max_digits=8, - decimal_places=2, + decimal_places=4, **field_kwargs ) @@ -231,7 +231,7 @@ def get_filterform_field(self, field, **kwargs): label=field, required=False, max_digits=12, - decimal_places=2, + decimal_places=4, min_value=field.validation_minimum, max_value=field.validation_maximum, ) From d0dad5bccc4d8573536e296f7378d0ee74beded9 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 15:15:41 +0000 Subject: [PATCH 08/13] feat: set lambda extra_kwargs to f.choices to match netbox.extras.choices --- netbox_custom_objects/filtersets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index c335d823..2b728cea 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -64,11 +64,11 @@ def build(self, field_name: str, label: str, queryset: Optional[QuerySet] = None CustomFieldTypeChoices.TYPE_JSON: FilterSpec(django_filters.CharFilter, lookup_expr="icontains"), CustomFieldTypeChoices.TYPE_SELECT: FilterSpec( django_filters.ChoiceFilter, - extra_kwargs={"choices": lambda f: f.get_choices()} + extra_kwargs={"choices": lambda f: f.choices} ), CustomFieldTypeChoices.TYPE_MULTISELECT: FilterSpec( django_filters.MultipleChoiceFilter, - extra_kwargs={"choices": lambda f: f.get_choices()} + extra_kwargs={"choices": lambda f: f.choices} ), CustomFieldTypeChoices.TYPE_OBJECT: FilterSpec(django_filters.ModelChoiceFilter), CustomFieldTypeChoices.TYPE_MULTIOBJECT: FilterSpec(django_filters.ModelMultipleChoiceFilter), From 25deee4df82bb76b24c881667396cb5c80cc2cb1 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 15:17:11 +0000 Subject: [PATCH 09/13] Feat: added related_object_type validation --- netbox_custom_objects/filtersets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 2b728cea..2194c6a0 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -94,7 +94,12 @@ def build_filter_for_field(field) -> Optional[django_filters.Filter]: CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT, ): - queryset = field.related_object_type.model_class().objects.all() + 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 + queryset = related_object_type.model_class().objects.all() extra_kwargs = {} if spec.extra_kwargs: From 71067f27f860944496a013e3f6b033927a4839aa Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 15:18:52 +0000 Subject: [PATCH 10/13] Doc: removed irrelevent comment --- netbox_custom_objects/field_types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 79a1fb41..d8cd21a4 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -506,7 +506,6 @@ def get_filterform_field(self, field, **kwargs): return DynamicModelChoiceField( queryset=field.related_object_type.model_class().objects.all(), required=False, - # Remove initial=field.default to allow Django to handle instance data properly query_params=( field.related_object_filter if hasattr(field, "related_object_filter") From 76f835864097abb96c3791bf300f93de9f5bf220 Mon Sep 17 00:00:00 2001 From: ifoughali Date: Thu, 15 Jan 2026 07:22:14 +0100 Subject: [PATCH 11/13] style: added missing blank line --- netbox_custom_objects/field_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index d8cd21a4..be950417 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -236,6 +236,7 @@ def get_filterform_field(self, field, **kwargs): max_value=field.validation_maximum, ) + class BooleanFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) From 3d048993795b006004ec9a11125b895e19143d49 Mon Sep 17 00:00:00 2001 From: ifoughali Date: Thu, 15 Jan 2026 07:22:31 +0100 Subject: [PATCH 12/13] style: line collapse --- netbox_custom_objects/filtersets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 2194c6a0..59ad6a8d 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -26,7 +26,9 @@ class FilterSpec: 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: + 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. From 0fb13105ee8557ac9444779178a26ce676646bb7 Mon Sep 17 00:00:00 2001 From: ifoughali Date: Mon, 23 Mar 2026 13:56:15 +0100 Subject: [PATCH 13/13] feat: simplified FIELD_TYPE_FILTERS null testing --- netbox_custom_objects/filtersets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 94db578a..64dc7e2a 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -88,8 +88,7 @@ class Meta: def build_filter_for_field(field) -> Optional[django_filters.Filter]: - spec = FIELD_TYPE_FILTERS.get(field.type) - if not spec: + if not (spec := FIELD_TYPE_FILTERS.get(field.type)): return None queryset = None