diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 251ac3d9..be950417 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 ) @@ -226,6 +226,16 @@ 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=4, + min_value=field.validation_minimum, + max_value=field.validation_maximum, + ) + class BooleanFieldType(FieldType): def get_model_field(self, field, **kwargs): @@ -491,7 +501,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 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) @@ -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=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) diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index a7fb16e0..64dc7e2a 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, Q from extras.choices import CustomFieldTypeChoices from netbox.filtersets import NetBoxModelFilterSet @@ -13,6 +16,67 @@ ) +@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.choices} + ), + CustomFieldTypeChoices.TYPE_MULTISELECT: FilterSpec( + django_filters.MultipleChoiceFilter, + extra_kwargs={"choices": lambda f: f.choices} + ), + CustomFieldTypeChoices.TYPE_OBJECT: FilterSpec(django_filters.ModelChoiceFilter), + CustomFieldTypeChoices.TYPE_MULTIOBJECT: FilterSpec(django_filters.ModelMultipleChoiceFilter), +} + + class CustomObjectTypeFilterSet(NetBoxModelFilterSet): class Meta: model = CustomObjectType @@ -23,10 +87,40 @@ class Meta: ) +def build_filter_for_field(field) -> Optional[django_filters.Filter]: + if not (spec := FIELD_TYPE_FILTERS.get(field.type)): + return None + + queryset = None + if field.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ): + related_object_type = getattr(field, "related_object_type", None) + if not related_object_type: + # Defensive guard: if data integrity is compromised and the related object type + # is missing, skip building a filter for this field rather than raising. + return None + queryset = 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, + **extra_kwargs, + ) + + def get_filterset_class(model): """ Create and return a filterset class for the given custom object model. """ + # Get standard fields from the model fields = [field.name for field in model._meta.fields] meta = type( @@ -35,8 +129,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, @@ -70,10 +162,16 @@ def search(self, queryset, name, value): attrs = { "Meta": meta, - "__module__": "database.filtersets", + "__module__": "netbox_custom_objects.filtersets", "search": search, } + # 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,),