Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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):
Expand Down Expand Up @@ -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
),
)
Comment thread
arthanson marked this conversation as resolved.

def render_table_column(self, value):
return linkify(value)
Expand Down Expand Up @@ -799,7 +820,18 @@ def get_form_field(self, field, for_csv_import=False, **kwargs):
)

def get_filterform_field(self, field, **kwargs):
return None
"""
Comment thread
ifoughal marked this conversation as resolved.
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
),
)
Comment thread
arthanson marked this conversation as resolved.

def get_display_value(self, instance, field_name):
field = getattr(instance, field_name)
Expand Down
106 changes: 102 additions & 4 deletions netbox_custom_objects/filtersets.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Comment thread
ifoughal marked this conversation as resolved.

# 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"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure on here - normally we do something like:
created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='lte'
)
created__after = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='gte'
)
the base does not have a lookup_expr. if we could add the __before, __after that would be great but probably beyond the scope of this - maybe add that as a FR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a draft for this logic, but it would require multiple changes, also to note on models.py

1272)  # TODO: Move all this logic to field_types.py get_filterform_field methods

maybe a refactor would be needed prior to implementing this?

Then, this logic should be added:

if self.type in (
        CustomFieldTypeChoices.TYPE_DATE,
        CustomFieldTypeChoices.TYPE_DATETIME,
    ):
        before_filter = self.to_filter(lookup_expr="lte")
        if before_filter is not None:
            filters[f"{self.name}__before"] = before_filter

        after_filter = self.to_filter(lookup_expr="gte")
        if after_filter is not None:
            filters[f"{self.name}__after"] = after_filter

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
Expand All @@ -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,
)
Comment thread
arthanson marked this conversation as resolved.


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

meta = type(
Expand All @@ -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,
Expand Down Expand Up @@ -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,),
Expand Down
Loading