From fc6ca6269bbcee1cd78cf2ac81db13b2c2cd1bd3 Mon Sep 17 00:00:00 2001 From: Idris Foughali Date: Mon, 12 Jan 2026 13:53:58 +0000 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 From d2ad072d951cc680a748e69be2d02c31b789f7b8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 27 Mar 2026 13:30:05 -0700 Subject: [PATCH 14/16] add tests, cleanup --- netbox_custom_objects/field_types.py | 333 +++++++----------- netbox_custom_objects/filtersets.py | 81 +++-- .../tests/test_filtersets.py | 313 ++++++++++++++++ 3 files changed, 486 insertions(+), 241 deletions(-) create mode 100644 netbox_custom_objects/tests/test_filtersets.py diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index be950417..47a561bf 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -15,16 +15,22 @@ from extras.choices import CustomFieldTypeChoices, CustomFieldUIEditableChoices from utilities.api import get_serializer_for_model from utilities.forms.fields import ( - CSVChoiceField, CSVModelChoiceField, - CSVModelMultipleChoiceField, CSVMultipleChoiceField, - DynamicChoiceField, DynamicModelChoiceField, + CSVChoiceField, + CSVModelChoiceField, + CSVModelMultipleChoiceField, + CSVMultipleChoiceField, + DynamicChoiceField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, - DynamicMultipleChoiceField, JSONField, + DynamicMultipleChoiceField, + JSONField, LaxURLField, ) from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import ( - APISelect, APISelectMultiple, DatePicker, + APISelect, + APISelectMultiple, + DatePicker, DateTimePicker, ) from utilities.templatetags.builtins.filters import linkify, render_markdown @@ -51,12 +57,13 @@ def __init__(self, to_model_name, *args, **kwargs): def contribute_to_class(self, cls, name, **kwargs): super().contribute_to_class(cls, name, **kwargs) # Mark this field for later resolution - setattr(cls, f"_resolve_{name}_model", self._resolve_model) + setattr(cls, f'_resolve_{name}_model', self._resolve_model) def _resolve_model(self, model): """Resolve the lazy reference to the actual model class.""" # Get the actual model class from the app registry from django.apps import apps + actual_model = apps.get_model(self._to_model_name) # Update the field's references self.remote_field.model = actual_model @@ -64,7 +71,6 @@ def _resolve_model(self, model): class FieldType: - def get_display_value(self, instance, field_name): """ This value is used as the object title in the Custom Object detail view. @@ -88,8 +94,7 @@ def _safe_kwargs(self, **kwargs): Create a safe kwargs dict that can be passed to Django field constructors. This method automatically filters out any custom parameters. """ - return {k: v for k, v in kwargs.items() - if not k.startswith('_') and k != 'generating_models'} + return {k: v for k, v in kwargs.items() if not k.startswith('_') and k != 'generating_models'} def get_annotated_form_field(self, field, enforce_visibility=True, **kwargs): form_field = self.get_form_field(field, **kwargs) @@ -119,10 +124,9 @@ def create_m2m_table(self, instance, model, field_name): ... class TextFieldType(FieldType): - def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.CharField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): @@ -132,15 +136,13 @@ def get_form_field(self, field, **kwargs): RegexValidator( regex=field.validation_regex, message=mark_safe( - _("Values must match this regex: {regex}").format( + _('Values must match this regex: {regex}').format( regex=escape(field.validation_regex) ) ), ) ] - return forms.CharField( - required=field.required, initial=field.default, validators=validators - ) + return forms.CharField(required=field.required, initial=field.default, validators=validators) def get_filterform_field(self, field, **kwargs): return forms.CharField( @@ -153,7 +155,7 @@ def get_filterform_field(self, field, **kwargs): class LongTextFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.TextField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): @@ -164,7 +166,7 @@ def get_form_field(self, field, **kwargs): RegexValidator( regex=field.validation_regex, message=mark_safe( - _("Values must match this regex: {regex}").format( + _('Values must match this regex: {regex}').format( regex=escape(field.validation_regex) ) ), @@ -182,11 +184,10 @@ def render_table_column(self, value): class IntegerFieldType(FieldType): - def get_model_field(self, field, **kwargs): # TODO: handle all args for IntegerField field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.IntegerField(null=True, blank=True, **field_kwargs) def get_filterform_field(self, field, **kwargs): @@ -207,14 +208,8 @@ def get_form_field(self, field, **kwargs): class DecimalFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) - return models.DecimalField( - null=True, - blank=True, - max_digits=8, - decimal_places=4, - **field_kwargs - ) + field_kwargs.update({'default': field.default, 'unique': field.unique}) + return models.DecimalField(null=True, blank=True, max_digits=8, decimal_places=4, **field_kwargs) def get_form_field(self, field, **kwargs): return forms.DecimalField( @@ -240,14 +235,14 @@ def get_filterform_field(self, field, **kwargs): class BooleanFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.BooleanField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): choices = ( - (None, "---------"), - (True, _("True")), - (False, _("False")), + (None, '---------'), + (True, _('True')), + (False, _('False')), ) return forms.NullBooleanField( required=field.required, @@ -262,43 +257,37 @@ def get_table_column_field(self, field, **kwargs): class DateFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.DateField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): - return forms.DateField( - required=field.required, initial=field.default, widget=DatePicker() - ) + return forms.DateField(required=field.required, initial=field.default, widget=DatePicker()) class DateTimeFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.DateTimeField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): - return forms.DateTimeField( - required=field.required, initial=field.default, widget=DateTimePicker() - ) + return forms.DateTimeField(required=field.required, initial=field.default, widget=DateTimePicker()) class URLFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.URLField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): - return LaxURLField( - assume_scheme="https", required=field.required, initial=field.default - ) + return LaxURLField(assume_scheme='https', required=field.required, initial=field.default) class JSONFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return models.JSONField(null=True, blank=True, **field_kwargs) def get_form_field(self, field, **kwargs): @@ -311,14 +300,8 @@ def get_form_field(self, field, **kwargs): class SelectFieldType(FieldType): def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) - return models.CharField( - max_length=100, - choices=field.choices, - null=True, - blank=True, - **field_kwargs - ) + field_kwargs.update({'default': field.default, 'unique': field.unique}) + return models.CharField(max_length=100, choices=field.choices, null=True, blank=True, **field_kwargs) def get_form_field(self, field, for_csv_import=False, **kwargs): choices = field.choice_set.choices @@ -334,9 +317,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): if for_csv_import: field_class = CSVChoiceField - return field_class( - choices=choices, required=field.required, initial=initial - ) + return field_class(choices=choices, required=field.required, initial=initial) else: field_class = DynamicChoiceField widget_class = APISelect @@ -344,24 +325,19 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): choices=choices, required=field.required, initial=initial, - widget=widget_class( - api_url=f"/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/" - ), + widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/'), ) class MultiSelectFieldType(FieldType): def get_display_value(self, instance, field_name): - return ", ".join(getattr(instance, field_name) or []) + return ', '.join(getattr(instance, field_name) or []) def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) return ArrayField( - base_field=models.CharField(max_length=50, choices=field.choices), - null=True, - blank=True, - **field_kwargs + base_field=models.CharField(max_length=50, choices=field.choices), null=True, blank=True, **field_kwargs ) def get_form_field(self, field, for_csv_import=False, **kwargs): @@ -378,9 +354,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): if for_csv_import: field_class = CSVMultipleChoiceField - return field_class( - choices=choices, required=field.required, initial=initial - ) + return field_class(choices=choices, required=field.required, initial=initial) else: field_class = DynamicMultipleChoiceField widget_class = APISelectMultiple @@ -388,9 +362,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): choices=choices, required=field.required, initial=initial, - widget=widget_class( - api_url=f"/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/" - ), + widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{field.choice_set.pk}/choices/'), ) # TODO: Implement this @@ -400,41 +372,54 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): # ) def render_table_column(self, value): - return ", ".join(value) + return ', '.join(value) + +class RelatedObjectFilterFormMixin: + """Mixin providing shared get_filterform_field logic for object reference field types.""" + + _filterform_field_class = None + + def get_filterform_field(self, field, **kwargs): + return self._filterform_field_class( + queryset=field.related_object_type.model_class().objects.all(), + required=False, + query_params=(field.related_object_filter if hasattr(field, 'related_object_filter') else None), + ) + + +class ObjectFieldType(RelatedObjectFilterFormMixin, FieldType): + _filterform_field_class = DynamicModelChoiceField -class ObjectFieldType(FieldType): def get_model_field(self, field, **kwargs): content_type = ContentType.objects.get(pk=field.related_object_type_id) to_model = content_type.model # Extract our custom parameters and keep only Django field parameters field_kwargs = {k: v for k, v in kwargs.items() if not k.startswith('_')} - field_kwargs.update({"default": field.default, "unique": field.unique}) + field_kwargs.update({'default': field.default, 'unique': field.unique}) # Handle self-referential fields by using string references if content_type.app_label == APP_LABEL: from netbox_custom_objects.models import CustomObjectType - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id) # Check if this is a self-referential field if custom_object_type.id == field.custom_object_type.id: # For self-referential fields, use LazyForeignKey to defer resolution - model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}" + model_name = f'{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}' # Generate a unique related_name to prevent reverse accessor conflicts table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() - related_name = f"{table_model_name}_{field.name}_set" + related_name = f'{table_model_name}_{field.name}_set' f = LazyForeignKey( model_name, null=True, blank=True, on_delete=models.CASCADE, related_name=related_name, - **field_kwargs + **field_kwargs, ) return f else: @@ -442,12 +427,12 @@ def get_model_field(self, field, **kwargs): model = custom_object_type.get_model(skip_object_fields=True) else: # to_model = content_type.model_class()._meta.object_name - to_ct = f"{content_type.app_label}.{to_model}" + to_ct = f'{content_type.app_label}.{to_model}' model = apps.get_model(to_ct) # Generate a unique related_name to prevent reverse accessor conflicts table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() - related_name = f"{table_model_name}_{field.name}_set" + related_name = f'{table_model_name}_{field.name}_set' f = models.ForeignKey( model, null=True, blank=True, on_delete=models.CASCADE, related_name=related_name, **field_kwargs ) @@ -466,9 +451,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): # This is a custom object type from netbox_custom_objects.models import CustomObjectType - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id) model = custom_object_type.get_model() @@ -492,28 +475,10 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): queryset=model.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 - ), + query_params=(field.related_object_filter if hasattr(field, 'related_object_filter') else None), selector=model._meta.app_label != APP_LABEL, ) - def get_filterform_field(self, field, **kwargs): - """ - Returns a filter form field for object relationships. - """ - return DynamicModelChoiceField( - queryset=field.related_object_type.model_class().objects.all(), - required=False, - query_params=( - field.related_object_filter - if hasattr(field, "related_object_filter") - else None - ), - ) - def render_table_column(self, value): return linkify(value) @@ -521,6 +486,7 @@ def get_serializer_field(self, field, **kwargs): related_model_class = field.related_object_type.model_class() if related_model_class._meta.app_label == APP_LABEL: from netbox_custom_objects.api.serializers import get_serializer_class + serializer = get_serializer_class(related_model_class, skip_object_fields=True) else: serializer = get_serializer_for_model(related_model_class) @@ -544,7 +510,7 @@ def __init__(self, instance=None, field_name=None): self.field = instance._meta.get_field(self.field_name) self.model = self.field.remote_field.model self.through = self.field.remote_field.through - self.core_filters = {"source_id": instance.pk} + self.core_filters = {'source_id': instance.pk} self.prefetch_cache_name = self.field_name def get_prefetch_queryset(self, instances, queryset=None): @@ -552,9 +518,9 @@ def get_prefetch_queryset(self, instances, queryset=None): queryset = self.get_queryset() # Get all the target IDs for these instances in a single query - through_queryset = self.through.objects.filter( - source_id__in=[obj.pk for obj in instances] - ).values_list("source_id", "target_id") + through_queryset = self.through.objects.filter(source_id__in=[obj.pk for obj in instances]).values_list( + 'source_id', 'target_id' + ) # Build a mapping of instance PKs to their related objects rel_obj_cache = {source_id: [] for source_id in [obj.pk for obj in instances]} @@ -570,17 +536,13 @@ def get_prefetch_queryset(self, instances, queryset=None): # Build the final cache mapping for source_id, target_ids in rel_obj_cache.items(): rel_obj_cache[source_id] = [ - target_objects[target_id] - for target_id in target_ids - if target_id in target_objects + target_objects[target_id] for target_id in target_ids if target_id in target_objects ] return ( target_queryset, # queryset containing all the related objects lambda obj: obj.pk, # function to get the related object ID - lambda obj: rel_obj_cache[ - obj.pk - ], # function to get the list of related objects + lambda obj: rel_obj_cache[obj.pk], # function to get the list of related objects False, # single related object (False for M2M) self.prefetch_cache_name, # cache name False, # is a descriptor (False for M2M) @@ -592,25 +554,19 @@ def get_queryset(self): # Join through the through table using a subquery qs = base_qs.filter( - pk__in=self.through.objects.filter(source_id=self.instance.pk).values_list( - "target_id", flat=True - ) + pk__in=self.through.objects.filter(source_id=self.instance.pk).values_list('target_id', flat=True) ) # Add default ordering by pk - return qs.order_by("pk") + return qs.order_by('pk') def add(self, *objs): for obj in objs: - self.through.objects.get_or_create( - source_id=self.instance.pk, target_id=obj.pk - ) + self.through.objects.get_or_create(source_id=self.instance.pk, target_id=obj.pk) def remove(self, *objs): for obj in objs: - self.through.objects.filter( - source_id=self.instance.pk, target_id=obj.pk - ).delete() + self.through.objects.filter(source_id=self.instance.pk, target_id=obj.pk).delete() def clear(self): self.through.objects.filter(source_id=self.instance.pk).delete() @@ -658,17 +614,17 @@ def __init__(self, *args, **kwargs): self.concrete = False def m2m_field_name(self): - return "source_id" + return 'source_id' def m2m_reverse_field_name(self): - return "target_id" + return 'target_id' def get_foreign_related_value(self, instance): """Get the related value for the instance.""" return (instance.pk,) def get_attname(self): - return f"{self.name}_id" + return f'{self.name}_id' def get_attname_column(self): return self.name, None @@ -679,11 +635,13 @@ def contribute_to_class(self, cls, name, **kwargs): def get_joining_columns(self, reverse_join=False): if reverse_join: - return ((self.m2m_reverse_field_name(), "id"),) - return ((self.m2m_field_name(), "id"),) + return ((self.m2m_reverse_field_name(), 'id'),) + return ((self.m2m_field_name(), 'id'),) + +class MultiObjectFieldType(RelatedObjectFilterFormMixin, FieldType): + _filterform_field_class = DynamicModelMultipleChoiceField -class MultiObjectFieldType(FieldType): def get_through_model(self, field, model_string): """ Creates a through model with deferred model references @@ -692,42 +650,39 @@ def get_through_model(self, field, model_string): # app_label = str(uuid.uuid4()) + "_database_table" # apps = AppsProxy(dynamic_models=None, app_label=app_label) meta = type( - "Meta", + 'Meta', (), { - "db_table": field.through_table_name, - "app_label": APP_LABEL, - "apps": apps, - "managed": True, - "unique_together": ("source", "target"), + 'db_table': field.through_table_name, + 'app_label': APP_LABEL, + 'apps': apps, + 'managed': True, + 'unique_together': ('source', 'target'), }, ) # Check if this is a self-referential M2M content_type = ContentType.objects.get(pk=field.related_object_type_id) - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') is_self_referential = ( - content_type.app_label == APP_LABEL - and field.custom_object_type.id == custom_object_type_id + content_type.app_label == APP_LABEL and field.custom_object_type.id == custom_object_type_id ) attrs = { - "__module__": "netbox_custom_objects.models", - "Meta": meta, - "id": models.AutoField(primary_key=True), - "source": models.ForeignKey( + '__module__': 'netbox_custom_objects.models', + 'Meta': meta, + 'id': models.AutoField(primary_key=True), + 'source': models.ForeignKey( model_string, on_delete=models.CASCADE, - related_name="+", - db_column="source_id", + related_name='+', + db_column='source_id', ), - "target": models.ForeignKey( - "self" if is_self_referential else model_string, + 'target': models.ForeignKey( + 'self' if is_self_referential else model_string, on_delete=models.CASCADE, - related_name="+", - db_column="target_id", + related_name='+', + db_column='target_id', ), } @@ -739,35 +694,32 @@ def get_model_field(self, field, **kwargs): """ # Check if this is a self-referential M2M content_type = ContentType.objects.get(pk=field.related_object_type_id) - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') # Extract our custom parameters and keep only Django field parameters field_kwargs = {k: v for k, v in kwargs.items() if not k.startswith('_')} # Remove default from field_kwargs since ManyToManyField doesn't handle defaults the same way - field_kwargs.update({"unique": field.unique}) + field_kwargs.update({'unique': field.unique}) is_self_referential = ( - content_type.app_label == APP_LABEL - and field.custom_object_type.id == custom_object_type_id + content_type.app_label == APP_LABEL and field.custom_object_type.id == custom_object_type_id ) # For now, we'll create the through model with string references # and resolve them later in after_model_generation # TODO: Check whether later resolution of the model is actually necessary or can be passed as string - model_string = f"{field.related_object_type.app_label}.{field.related_object_type.model}" + model_string = f'{field.related_object_type.app_label}.{field.related_object_type.model}' through = self.get_through_model(field, model_string) # For self-referential fields, use 'self' as the target m2m_field = CustomManyToManyField( - to="self" if is_self_referential else model_string, + to='self' if is_self_referential else model_string, through=through, - through_fields=("source", "target"), + through_fields=('source', 'target'), blank=True, - related_name="+", - related_query_name="+", - **field_kwargs + related_name='+', + related_query_name='+', + **field_kwargs, ) # Store metadata for later resolution @@ -787,9 +739,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): # This is a custom object type from netbox_custom_objects.models import CustomObjectType - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id) model = custom_object_type.get_model(skip_object_fields=True) @@ -811,31 +761,13 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): return field_class( queryset=model.objects.all(), required=field.required, - query_params=( - field.related_object_filter - if hasattr(field, "related_object_filter") - else None - ), + query_params=(field.related_object_filter if hasattr(field, 'related_object_filter') else None), selector=model._meta.app_label != APP_LABEL, ) - def get_filterform_field(self, field, **kwargs): - """ - Returns a filter form field for multi-object relationships. - """ - return DynamicModelMultipleChoiceField( - queryset=field.related_object_type.model_class().objects.all(), - required=False, - 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) - return ", ".join(str(s) for s in field.all()) + return ', '.join(str(s) for s in field.all()) def get_table_column_field(self, field, **kwargs): return tables.ManyToManyColumn(linkify_item=True, orderable=False) @@ -844,6 +776,7 @@ def get_serializer_field(self, field, **kwargs): related_model_class = field.related_object_type.model_class() if related_model_class._meta.app_label == APP_LABEL: from netbox_custom_objects.api.serializers import get_serializer_class + serializer = get_serializer_class(related_model_class, skip_object_fields=True) else: serializer = get_serializer_for_model(related_model_class) @@ -856,13 +789,13 @@ def after_model_generation(self, instance, model, field_name): field = model._meta.get_field(field_name) # Skip model resolution for self-referential fields - if getattr(field, "_is_self_referential", False): + if getattr(field, '_is_self_referential', False): field.remote_field.model = model through_model = field.remote_field.through # Update both source and target fields to point to the same model - source_field = through_model._meta.get_field("source") - target_field = through_model._meta.get_field("target") + source_field = through_model._meta.get_field('source') + target_field = through_model._meta.get_field('target') # Resolve the foreign key fields to point to the actual model source_field.remote_field.model = model @@ -883,9 +816,7 @@ def after_model_generation(self, instance, model, field_name): if content_type.app_label == APP_LABEL: from netbox_custom_objects.models import CustomObjectType - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id) # For self-referential fields, we need to resolve them to the current model @@ -896,7 +827,7 @@ def after_model_generation(self, instance, model, field_name): else: to_model = custom_object_type.get_model() else: - to_ct = f"{content_type.app_label}.{content_type.model}" + to_ct = f'{content_type.app_label}.{content_type.model}' to_model = apps.get_model(to_ct) # Update through model's fields @@ -904,8 +835,8 @@ def after_model_generation(self, instance, model, field_name): # Update through model's target field through_model = field.remote_field.through - source_field = through_model._meta.get_field("source") - target_field = through_model._meta.get_field("target") + source_field = through_model._meta.get_field('source') + target_field = through_model._meta.get_field('target') # Source field should point to the current model source_field.remote_field.model = model @@ -925,19 +856,15 @@ def create_m2m_table(self, instance, model, field_name): field = model._meta.get_field(field_name) # For self-referential fields, use the current model - if getattr(field, "_is_self_referential", False): + if getattr(field, '_is_self_referential', False): to_model = model else: content_type = ContentType.objects.get(pk=instance.related_object_type_id) if content_type.app_label == APP_LABEL: from netbox_custom_objects.models import CustomObjectType - custom_object_type_id = content_type.model.replace("table", "").replace( - "model", "" - ) - custom_object_type = CustomObjectType.objects.get( - pk=custom_object_type_id - ) + custom_object_type_id = content_type.model.replace('table', '').replace('model', '') + custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id) to_model = custom_object_type.get_model() else: @@ -947,8 +874,8 @@ def create_m2m_table(self, instance, model, field_name): through = self.get_through_model(instance, model) # Update the through model's foreign key references - source_field = through._meta.get_field("source") - target_field = through._meta.get_field("target") + source_field = through._meta.get_field('source') + target_field = through._meta.get_field('target') # Source field should point to the current model source_field.remote_field.model = model diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 64dc7e2a..a47ca927 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -7,12 +7,18 @@ from extras.choices import CustomFieldTypeChoices from netbox.filtersets import NetBoxModelFilterSet +from utilities.filters import ( + MultiValueDateFilter, + MultiValueDateTimeFilter, + MultiValueDecimalFilter, + MultiValueNumberFilter, +) from .models import CustomObjectType __all__ = ( - "CustomObjectTypeFilterSet", - "get_filterset_class", + 'CustomObjectTypeFilterSet', + 'get_filterset_class', ) @@ -22,27 +28,28 @@ 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: + ) -> django_filters.Filter: """ Instantiate and return a django-filter Filter. Allows overriding defaults via **kwargs. """ filter_kwargs = { - "field_name": field_name, - "label": label, + 'field_name': field_name, + 'label': label, } if self.lookup_expr: - filter_kwargs["lookup_expr"] = self.lookup_expr + filter_kwargs['lookup_expr'] = self.lookup_expr if queryset is not None: - filter_kwargs["queryset"] = queryset + filter_kwargs['queryset'] = queryset # Apply defaults from the spec if self.extra_kwargs: @@ -55,22 +62,20 @@ def build( 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_TEXT: FilterSpec(django_filters.CharFilter, lookup_expr='icontains'), + CustomFieldTypeChoices.TYPE_LONGTEXT: FilterSpec(django_filters.CharFilter, lookup_expr='icontains'), + CustomFieldTypeChoices.TYPE_INTEGER: FilterSpec(MultiValueNumberFilter, lookup_expr='exact'), + CustomFieldTypeChoices.TYPE_DECIMAL: FilterSpec(MultiValueDecimalFilter, 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_DATE: FilterSpec(MultiValueDateFilter, lookup_expr='exact'), + CustomFieldTypeChoices.TYPE_DATETIME: FilterSpec(MultiValueDateTimeFilter, 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} + django_filters.ChoiceFilter, extra_kwargs={'choices': lambda f: f.choices} ), CustomFieldTypeChoices.TYPE_MULTISELECT: FilterSpec( - django_filters.MultipleChoiceFilter, - extra_kwargs={"choices": lambda f: f.choices} + django_filters.MultipleChoiceFilter, extra_kwargs={'choices': lambda f: f.choices} ), CustomFieldTypeChoices.TYPE_OBJECT: FilterSpec(django_filters.ModelChoiceFilter), CustomFieldTypeChoices.TYPE_MULTIOBJECT: FilterSpec(django_filters.ModelMultipleChoiceFilter), @@ -81,9 +86,9 @@ class CustomObjectTypeFilterSet(NetBoxModelFilterSet): class Meta: model = CustomObjectType fields = ( - "id", - "name", - "group_name", + 'id', + 'name', + 'group_name', ) @@ -96,7 +101,7 @@ def build_filter_for_field(field) -> Optional[django_filters.Filter]: CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT, ): - related_object_type = getattr(field, "related_object_type", None) + 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. @@ -124,22 +129,22 @@ def get_filterset_class(model): fields = [field.name for field in model._meta.fields] meta = type( - "Meta", + 'Meta', (), { - "model": model, - "fields": fields, - "filter_overrides": { + 'model': model, + 'fields': fields, + 'filter_overrides': { JSONField: { - "filter_class": django_filters.CharFilter, - "extra": lambda f: { - "lookup_expr": "icontains", + 'filter_class': django_filters.CharFilter, + 'extra': lambda f: { + 'lookup_expr': 'icontains', }, }, ArrayField: { - "filter_class": django_filters.CharFilter, - "extra": lambda f: { - "lookup_expr": "icontains", + 'filter_class': django_filters.CharFilter, + 'extra': lambda f: { + 'lookup_expr': 'icontains', }, }, }, @@ -157,13 +162,13 @@ def search(self, queryset, name, value): CustomFieldTypeChoices.TYPE_JSON, CustomFieldTypeChoices.TYPE_URL, ]: - q |= Q(**{f"{field.name}__icontains": value}) + q |= Q(**{f'{field.name}__icontains': value}) return queryset.filter(q) attrs = { - "Meta": meta, - "__module__": "netbox_custom_objects.filtersets", - "search": search, + 'Meta': meta, + '__module__': 'netbox_custom_objects.filtersets', + 'search': search, } # For each custom field, add a corresponding filter @@ -173,7 +178,7 @@ def search(self, queryset, name, value): attrs[field.name] = filter_instance return type( - f"{model._meta.object_name}FilterSet", + f'{model._meta.object_name}FilterSet', (NetBoxModelFilterSet,), attrs, ) diff --git a/netbox_custom_objects/tests/test_filtersets.py b/netbox_custom_objects/tests/test_filtersets.py new file mode 100644 index 00000000..76f89ec4 --- /dev/null +++ b/netbox_custom_objects/tests/test_filtersets.py @@ -0,0 +1,313 @@ +import datetime +from decimal import Decimal +from itertools import chain + +import django_filters +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel +from django.test import TestCase + +try: + from taggit.managers import TaggableManager +except ImportError: + TaggableManager = None + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site + +from netbox_custom_objects.filtersets import CustomObjectTypeFilterSet, get_filterset_class +from netbox_custom_objects.models import CustomObjectType +from .base import CustomObjectsTestCase + + +EXEMPT_MODEL_FIELDS = ( + 'comments', + 'custom_field_data', + 'level', + 'lft', + 'rght', + 'tree_id', +) + + +class BaseFilterSetTests: + """ + Mixin that asserts every model field has a corresponding filter defined on its FilterSet. + Fields intentionally not filterable should be listed in ignore_fields. + """ + + ignore_fields = () + + def _get_filters_for_field(self, field): + if issubclass(field.__class__, ForeignKey) or type(field) is OneToOneRel: + if field.related_model is ContentType: + return [(None, None)] + return [(f'{field.name}_id', django_filters.ModelMultipleChoiceFilter)] + + if type(field) in (ManyToManyField, ManyToManyRel): + if field.related_model is ContentType: + return [ + ('object_type', None), + ('object_type_id', django_filters.ModelMultipleChoiceFilter), + ] + related_name = field.related_model._meta.verbose_name.lower().replace(' ', '_') + return [(f'{related_name}_id', django_filters.ModelMultipleChoiceFilter)] + + if TaggableManager is not None and type(field) is TaggableManager: + return [('tag', None)] + + return [(field.name, None)] + + def test_missing_filters(self): + model = self.queryset.model + defined_filters = self.filterset.get_filters() + + for model_field in model._meta.get_fields(): + if model_field.name.startswith('_'): + continue + if model_field.name in chain(self.ignore_fields, EXEMPT_MODEL_FIELDS): + continue + if type(model_field) is ManyToOneRel: + continue + if type(model_field) in (GenericForeignKey, GenericRelation): + continue + + for filter_name, filter_class in self._get_filters_for_field(model_field): + if filter_name is None: + continue + self.assertIn( + filter_name, + defined_filters.keys(), + f'No filter defined for {filter_name} ({model_field.name})!', + ) + if filter_class is not None: + self.assertIsInstance( + defined_filters[filter_name], + filter_class, + f'Invalid filter class for {filter_name} (expected {filter_class})!', + ) + + +class CustomObjectTypeFilterSetTestCase(CustomObjectsTestCase, TestCase, BaseFilterSetTests): + filterset = CustomObjectTypeFilterSet + # Fields intentionally not covered by CustomObjectTypeFilterSet + ignore_fields = ( + 'slug', + 'description', + 'verbose_name_plural', + ) + + @classmethod + def setUpTestData(cls): + CustomObjectType.objects.create(name='Type 1', slug='type-1') + CustomObjectType.objects.create(name='Type 2', slug='type-2', group_name='Group A') + CustomObjectType.objects.create(name='Type 3', slug='type-3', group_name='Group A') + + @property + def queryset(self): + return CustomObjectType.objects.all() + + def test_id(self): + params = {'id': list(CustomObjectType.objects.values_list('pk', flat=True)[:2])} + self.assertEqual(self.filterset(params, CustomObjectType.objects.all()).qs.count(), 2) + + def test_name(self): + params = {'name': ['Type 1', 'Type 2']} + self.assertEqual(self.filterset(params, CustomObjectType.objects.all()).qs.count(), 2) + + def test_group_name(self): + params = {'group_name': ['Group A']} + self.assertEqual(self.filterset(params, CustomObjectType.objects.all()).qs.count(), 2) + + def test_q(self): + params = {'q': 'Type 1'} + self.assertEqual(self.filterset(params, CustomObjectType.objects.all()).qs.count(), 1) + + +class CustomObjectFilterSetTestCase(CustomObjectsTestCase, TestCase): + """ + Tests for dynamically generated filtersets on custom object instances. + Verifies that a filter for each supported field type is functional and + returns the correct results. Range filters (__lte/__gte) on date and numeric + fields are auto-generated by NetBoxModelFilterSet via get_additional_lookups(). + """ + + @classmethod + def setUpTestData(cls): + # Devices used for object/multiobject field tests + manufacturer = Manufacturer.objects.create(name='FS Manufacturer', slug='fs-manufacturer') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='FS Device Type', slug='fs-device-type' + ) + role = DeviceRole.objects.create(name='FS Role', slug='fs-role', color='ff0000') + site = Site.objects.create(name='FS Site', slug='fs-site') + cls.device1 = Device.objects.create(name='FS Device 1', device_type=device_type, role=role, site=site) + cls.device2 = Device.objects.create(name='FS Device 2', device_type=device_type, role=role, site=site) + + choice_set = CustomObjectsTestCase.create_choice_set(name='FS Choice Set') + device_object_type = CustomObjectsTestCase.get_device_object_type() + + cls.cot = CustomObjectsTestCase.create_custom_object_type(name='FilterSetObject', slug='filterset-objects') + + for field_def in [ + {'name': 'text_field', 'label': 'Text Field', 'type': 'text'}, + {'name': 'longtext_field', 'label': 'Long Text Field', 'type': 'longtext'}, + {'name': 'int_field', 'label': 'Integer Field', 'type': 'integer'}, + {'name': 'decimal_field', 'label': 'Decimal Field', 'type': 'decimal'}, + {'name': 'bool_field', 'label': 'Boolean Field', 'type': 'boolean'}, + {'name': 'date_field', 'label': 'Date Field', 'type': 'date'}, + {'name': 'url_field', 'label': 'URL Field', 'type': 'url'}, + {'name': 'json_field', 'label': 'JSON Field', 'type': 'json'}, + ]: + CustomObjectsTestCase.create_custom_object_type_field(cls.cot, **field_def) + + CustomObjectsTestCase.create_custom_object_type_field( + cls.cot, name='select_field', label='Select Field', type='select', choice_set=choice_set + ) + CustomObjectsTestCase.create_custom_object_type_field( + cls.cot, name='device_field', label='Device Field', type='object', related_object_type=device_object_type + ) + CustomObjectsTestCase.create_custom_object_type_field( + cls.cot, + name='devices_field', + label='Devices Field', + type='multiobject', + related_object_type=device_object_type, + ) + + cls.model = cls.cot.get_model() + cls.filterset = get_filterset_class(cls.model) + + cls.obj1 = cls.model.objects.create( + text_field='Alpha value', + longtext_field='Alpha long text', + int_field=10, + decimal_field=Decimal('1.5000'), + bool_field=True, + date_field=datetime.date(2024, 1, 1), + url_field='https://alpha.example.com', + json_field={'tag': 'alpha'}, + select_field='choice1', + device_field=cls.device1, + ) + cls.obj2 = cls.model.objects.create( + text_field='Beta value', + longtext_field='Beta long text', + int_field=20, + decimal_field=Decimal('2.5000'), + bool_field=False, + date_field=datetime.date(2024, 6, 15), + url_field='https://beta.example.com', + json_field={'tag': 'beta'}, + select_field='choice2', + device_field=cls.device2, + ) + cls.obj3 = cls.model.objects.create( + text_field='Gamma value', + longtext_field='Gamma long text', + int_field=30, + decimal_field=Decimal('3.5000'), + bool_field=True, + date_field=datetime.date(2024, 12, 31), + url_field='https://gamma.example.com', + json_field={'tag': 'gamma'}, + select_field='choice1', + device_field=cls.device1, + ) + + cls.obj1.devices_field.add(cls.device1) + cls.obj2.devices_field.add(cls.device2) + cls.obj3.devices_field.add(cls.device1, cls.device2) + + @property + def queryset(self): + return self.model.objects.all() + + # --- Text types (icontains) --- + + def test_text_field(self): + params = {'text_field': 'alpha'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_longtext_field(self): + params = {'longtext_field': 'beta'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_url_field(self): + params = {'url_field': 'gamma'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_json_field(self): + params = {'json_field': 'alpha'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + # --- Numeric types (exact + range lookups) --- + + def test_integer_field(self): + params = {'int_field': 20} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_integer_field_lte(self): + params = {'int_field__lte': 20} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_integer_field_gte(self): + params = {'int_field__gte': 20} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_decimal_field(self): + params = {'decimal_field': '2.5'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_decimal_field_lte(self): + params = {'decimal_field__lte': '2.5'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_decimal_field_gte(self): + params = {'decimal_field__gte': '2.5'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + # --- Boolean --- + + def test_boolean_field_true(self): + params = {'bool_field': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_boolean_field_false(self): + params = {'bool_field': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + # --- Date (exact + range lookups auto-generated by NetBoxModelFilterSet) --- + + def test_date_field(self): + params = {'date_field': '2024-01-01'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_date_field_lte(self): + # obj1 (2024-01-01) and obj2 (2024-06-15) are on or before 2024-06-15 + params = {'date_field__lte': '2024-06-15'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_date_field_gte(self): + # obj2 (2024-06-15) and obj3 (2024-12-31) are on or after 2024-06-15 + params = {'date_field__gte': '2024-06-15'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + # --- Choice --- + + def test_select_field(self): + # obj1 and obj3 have choice1 + params = {'select_field': 'choice1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + # --- Object references --- + + def test_object_field(self): + # obj1 and obj3 reference device1 + params = {'device_field': self.device1.pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_multiobject_field(self): + # obj2 and obj3 reference device2 + params = {'devices_field': [self.device2.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From e560a68fe5b36ce0c17e6f71b8797457fc91ec4a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 27 Mar 2026 13:57:15 -0700 Subject: [PATCH 15/16] backout ruff quote changes --- netbox_custom_objects/field_types.py | 74 ++++++++++++++-------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 150feaa9..ebad4e5a 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -136,7 +136,7 @@ def get_form_field(self, field, **kwargs): RegexValidator( regex=field.validation_regex, message=mark_safe( - _('Values must match this regex: {regex}').format( + _("Values must match this regex: {regex}").format( regex=escape(field.validation_regex) ) ), @@ -166,7 +166,7 @@ def get_form_field(self, field, **kwargs): RegexValidator( regex=field.validation_regex, message=mark_safe( - _('Values must match this regex: {regex}').format( + _("Values must match this regex: {regex}").format( regex=escape(field.validation_regex) ) ), @@ -240,9 +240,9 @@ def get_model_field(self, field, **kwargs): def get_form_field(self, field, **kwargs): choices = ( - (None, '---------'), - (True, _('True')), - (False, _('False')), + (None, "---------"), + (True, _("True")), + (False, _("False")), ) return forms.NullBooleanField( required=field.required, @@ -331,7 +331,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): class MultiSelectFieldType(FieldType): def get_display_value(self, instance, field_name): - return ', '.join(getattr(instance, field_name) or []) + return ", ".join(getattr(instance, field_name) or []) def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) @@ -372,7 +372,7 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): # ) def render_table_column(self, value): - return ', '.join(value) + return ", ".join(value) class RelatedObjectFilterFormMixin: @@ -437,7 +437,7 @@ def get_model_field(self, field, **kwargs): model = custom_object_type.get_model(skip_object_fields=True) else: # to_model = content_type.model_class()._meta.object_name - to_ct = f'{content_type.app_label}.{to_model}' + to_ct = f"{content_type.app_label}.{to_model}" model = apps.get_model(to_ct) # Generate a unique related_name to prevent reverse accessor conflicts @@ -520,7 +520,7 @@ def __init__(self, instance=None, field_name=None): self.field = instance._meta.get_field(self.field_name) self.model = self.field.remote_field.model self.through = self.field.remote_field.through - self.core_filters = {'source_id': instance.pk} + self.core_filters = {"source_id": instance.pk} self.prefetch_cache_name = self.field_name def get_prefetch_queryset(self, instances, queryset=None): @@ -568,7 +568,7 @@ def get_queryset(self): ) # Add default ordering by pk - return qs.order_by('pk') + return qs.order_by("pk") def add(self, *objs): for obj in objs: @@ -624,17 +624,17 @@ def __init__(self, *args, **kwargs): self.concrete = False def m2m_field_name(self): - return 'source_id' + return "source_id" def m2m_reverse_field_name(self): - return 'target_id' + return "target_id" def get_foreign_related_value(self, instance): """Get the related value for the instance.""" return (instance.pk,) def get_attname(self): - return f'{self.name}_id' + return f"{self.name}_id" def get_attname_column(self): return self.name, None @@ -645,8 +645,8 @@ def contribute_to_class(self, cls, name, **kwargs): def get_joining_columns(self, reverse_join=False): if reverse_join: - return ((self.m2m_reverse_field_name(), 'id'),) - return ((self.m2m_field_name(), 'id'),) + return ((self.m2m_reverse_field_name(), "id"),) + return ((self.m2m_field_name(), "id"),) class MultiObjectFieldType(RelatedObjectFilterFormMixin, FieldType): @@ -660,14 +660,14 @@ def get_through_model(self, field, model_string): # app_label = str(uuid.uuid4()) + "_database_table" # apps = AppsProxy(dynamic_models=None, app_label=app_label) meta = type( - 'Meta', + "Meta", (), { - 'db_table': field.through_table_name, - 'app_label': APP_LABEL, - 'apps': apps, - 'managed': True, - 'unique_together': ('source', 'target'), + "db_table": field.through_table_name, + "app_label": APP_LABEL, + "apps": apps, + "managed": True, + "unique_together": ("source", "target"), }, ) @@ -679,20 +679,20 @@ def get_through_model(self, field, model_string): ) attrs = { - '__module__': 'netbox_custom_objects.models', - 'Meta': meta, - 'id': models.AutoField(primary_key=True), - 'source': models.ForeignKey( + "__module__": "netbox_custom_objects.models", + "Meta": meta, + "id": models.AutoField(primary_key=True), + "source": models.ForeignKey( model_string, on_delete=models.CASCADE, - related_name='+', - db_column='source_id', + related_name="+", + db_column="source_id", ), - 'target': models.ForeignKey( + "target": models.ForeignKey( 'self' if is_self_referential else model_string, on_delete=models.CASCADE, - related_name='+', - db_column='target_id', + related_name="+", + db_column="target_id", ), } @@ -725,10 +725,10 @@ def get_model_field(self, field, **kwargs): m2m_field = CustomManyToManyField( to='self' if is_self_referential else model_string, through=through, - through_fields=('source', 'target'), + through_fields=("source", "target"), blank=True, - related_name='+', - related_query_name='+', + related_name="+", + related_query_name="+", **field_kwargs, ) @@ -799,7 +799,7 @@ def after_model_generation(self, instance, model, field_name): field = model._meta.get_field(field_name) # Skip model resolution for self-referential fields - if getattr(field, '_is_self_referential', False): + if getattr(field, "_is_self_referential", False): field.remote_field.model = model through_model = field.remote_field.through @@ -837,7 +837,7 @@ def after_model_generation(self, instance, model, field_name): else: to_model = custom_object_type.get_model() else: - to_ct = f'{content_type.app_label}.{content_type.model}' + to_ct = f"{content_type.app_label}.{content_type.model}" to_model = apps.get_model(to_ct) # Update through model's fields @@ -846,7 +846,7 @@ def after_model_generation(self, instance, model, field_name): # Update through model's target field through_model = field.remote_field.through source_field = through_model._meta.get_field('source') - target_field = through_model._meta.get_field('target') + target_field = through_model._meta.get_field("target") # Source field should point to the current model source_field.remote_field.model = model @@ -866,7 +866,7 @@ def create_m2m_table(self, instance, model, field_name): field = model._meta.get_field(field_name) # For self-referential fields, use the current model - if getattr(field, '_is_self_referential', False): + if getattr(field, "_is_self_referential", False): to_model = model else: content_type = ContentType.objects.get(pk=instance.related_object_type_id) From 585e072e0d89d06b3645deaa5fce4a2bfe95c279 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 27 Mar 2026 14:01:30 -0700 Subject: [PATCH 16/16] backout ruff quote changes --- netbox_custom_objects/filtersets.py | 50 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 7c0e3b7b..068c8462 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -17,8 +17,8 @@ from .models import CustomObjectType __all__ = ( - 'CustomObjectTypeFilterSet', - 'get_filterset_class', + "CustomObjectTypeFilterSet", + "get_filterset_class", ) @@ -86,9 +86,9 @@ class CustomObjectTypeFilterSet(NetBoxModelFilterSet): class Meta: model = CustomObjectType fields = ( - 'id', - 'name', - 'group_name', + "id", + "name", + "group_name", ) @@ -129,22 +129,24 @@ def get_filterset_class(model): fields = [field.name for field in model._meta.fields] meta = type( - 'Meta', + "Meta", (), { - 'model': model, - 'fields': fields, - 'filter_overrides': { + "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', + "filter_class": django_filters.CharFilter, + "extra": lambda f: { + "lookup_expr": "icontains", }, }, ArrayField: { - 'filter_class': django_filters.CharFilter, - 'extra': lambda f: { - 'lookup_expr': 'icontains', + "filter_class": django_filters.CharFilter, + "extra": lambda f: { + "lookup_expr": "icontains", }, }, }, @@ -162,13 +164,13 @@ def search(self, queryset, name, value): CustomFieldTypeChoices.TYPE_JSON, CustomFieldTypeChoices.TYPE_URL, ]: - q |= Q(**{f'{field.name}__icontains': value}) + q |= Q(**{f"{field.name}__icontains": value}) return queryset.filter(q) attrs = { - 'Meta': meta, - '__module__': 'netbox_custom_objects.filtersets', - 'search': search, + "Meta": meta, + "__module__": "netbox_custom_objects.filtersets", + "search": search, } # For each custom field, add a corresponding filter. @@ -194,13 +196,15 @@ 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) + 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}' + filter_m2m.__name__ = f"filter_{fname}" return filter_m2m - method_name = f'filter_{field_name}' + 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(), @@ -209,7 +213,7 @@ def filter_m2m(self, queryset, name, value): ) return type( - f'{model._meta.object_name}FilterSet', + f"{model._meta.object_name}FilterSet", (NetBoxModelFilterSet,), attrs, )