mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-10 18:39:36 -06:00
Closes #7604: Add filter modifier dropdowns for advanced lookup operators (#20747)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
* Fixes #7604: Add filter modifier dropdowns for advanced lookup operators Implements dynamic filter modifier UI that allows users to select lookup operators (exact, contains, starts with, regex, negation, empty/not empty) directly in filter forms without manual URL parameter editing. Supports filters for all scalar types and strings, as well as some related object filters. Explicitly does not support filters on fields that use APIWidget. That has been broken out in to follow up work. **Backend:** - FilterModifierWidget: Wraps form widgets with lookup modifier dropdown - FilterModifierMixin: Auto-enhances filterset fields with appropriate lookups - Extended lookup support: Adds negation (n), regex, iregex, empty_true/false lookups - Field-type-aware: CharField gets text lookups, IntegerField gets comparison operators, etc. **Frontend:** - TypeScript handler syncs modifier dropdown with URL parameters - Dynamically updates form field names (serial → serial__ic) on modifier change - Flexible-width modifier dropdowns with semantic CSS classes * Remove extraneous TS comments * Fix import order * Fix CircuitFilterForm inheritance * Enable filter form modifiers on DCIM models * Enable filter form modifiers on Tenancy models * Enable filter form modifiers on Wireless models * Enable filter form modifiers on IPAM models * Enable filter form modifiers on VPN models * Enable filter form modifiers on Virtualization models * Enable filter form modifiers on Circuit models * Enable filter form modifiers on Users models * Enable filter form modifiers on Core models * Enable filter form modifiers on Extras models * Add ChoiceField support to FilterModifierMixin Enable filter modifiers for single-choice ChoiceFields in addition to the existing MultipleChoiceField support. ChoiceFields can now display modifier dropdowns with "Is", "Is Not", "Is Empty", and "Is Not Empty" options when the corresponding FilterSet defines those lookups. The mixin correctly verifies lookup availability against the FilterSet, so modifiers only appear when multiple lookup options are actually supported. Currently most FilterSets only define 'exact' for single-choice fields, but this change enables future FilterSet enhancements to expose additional lookups for ChoiceFields. * Address PR feedback: Replace global filterset mappings with registry * Address PR feedback: Move FilterModifierMixin into base filter form classes Incorporates FilterModifierMixin into NetBoxModelFilterSetForm and FilterForm, making filter modifiers automatic for all filter forms throughout the application. * Fix filter modifier form submission bug with 'action' field collision Forms with a field named "action" (e.g., ObjectChangeFilterForm) were causing the form.action property to be shadowed by the field element, resulting in [object HTMLSelectElement] appearing in the URL path. Use form.getAttribute('action') instead of form.action to reliably retrieve the form's action URL without collision from form fields. Fixes form submission on /core/changelog/ and any other forms with an 'action' field using filter modifiers. * Address PR feedback: Move FORM_FIELD_LOOKUPS to module-level constant Extracts the field type to lookup mappings from FilterModifierMixin class attribute to a module-level constant for better reusability. * Address PR feedback: Refactor and consolidate field filtering logic Consolidated field enhancement logic in FilterModifierMixin by: - Creating QueryField marker type (CharField subclass) for search fields - Updating FilterForm and NetBoxModelFilterSetForm to use QueryField for 'q' - Moving all skip logic into _get_lookup_choices() to return empty list for fields that shouldn't be enhanced - Removing separate _should_skip_field() method - Removing unused field_name parameter from _get_lookup_choices() - Replacing hardcoded field name check ('q') with type-based detection * Address PR feedback: Refactor applied_filters to use FORM_FIELD_LOOKUPS * Address PR feedback: Rename FilterModifierWidget parameter to widget * Fix registry pattern to use model identifiers as keys Changed filterset registration to use model identifiers ('{app_label}.{model_name}') as registry keys instead of form classes, matching NetBox's pattern for search indexes. * Address PR feedback: refactor brittle test for APISelect useage Now checks if widget is actually APISelect, rather than trying to infer from the class name. * Refactor register_filterset to be more generic and simple * Remove unneeded imports left from earlier registry work * Update app registry for new `filtersets` store * Remove unused star import, leftover from earlier work * Enables filter modifiers on APISelect based fields * Support filter modifiers for ChoiceField * Include MODIFIER_EMPTY_FALSE/_TRUE in __all__ Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com> * Fix filterset registration for doubly-registered models * Removed explicit checks against QueryField and [Null]BooleanField I did add them to FORM_FIELD_LOOKUPS, though, to underscore that they were considered and are intentially empty for future devs. * Switch to sentence case for filter pill text * Fix applied_filters template tag to use field-type-specific lookup labelsresolves E.g. resolves gt="after" for dates vs "greater than" for numbers * Verifies that filter pills for exact matches (no lookup Add test for exact lookup filter pill rendering * Add guard for FilterModifierWidget with no lookups * Remove comparison symbols from numeric filter labels * Match complete tags in widget rendering test assertions * Check all expected lookups in field enhancement tests * Move register_filterset to netbox.plugins.registration * Require registered filterset for filter modifier enhancements Updates FilterModifierMixin to only enhance form fields when the associated model has a registered filterset. This provides plugin safety by ensuring unregistered plugin filtersets fall back to simple filters without lookup modifiers. Test changes: - Create TestModel and TestFilterSet using BaseFilterSet for automatic lookup generation - Import dcim.filtersets to ensure Device filterset registration - Adjust tag field expectations to match actual Device filterset (has exact/n but not empty lookups) * Attempt to resolve static conflicts * Move register_filterset() back to utilities.filtersets * Add register_filterset() to plugins documentation for filtersets * Reorder import statements --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
parent
20c260b126
commit
7eefb07554
@ -20,6 +20,10 @@ A dictionary mapping data backend types to their respective classes. These are u
|
||||
|
||||
Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
|
||||
|
||||
### `filtersets`
|
||||
|
||||
A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.
|
||||
|
||||
### `model_features`
|
||||
|
||||
A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
|
||||
|
||||
@ -6,12 +6,17 @@ Filter sets define the mechanisms available for filtering or searching through a
|
||||
|
||||
To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
|
||||
|
||||
!!! info "New in NetBox v4.5: FilterSet Registration"
|
||||
NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
|
||||
|
||||
```python
|
||||
# filtersets.py
|
||||
import django_filters
|
||||
from netbox.filtersets import NetBoxModelFilterSet
|
||||
from utilities.filtersets import register_filterset
|
||||
from .models import MyModel
|
||||
|
||||
@register_filterset
|
||||
class MyFilterSet(NetBoxModelFilterSet):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=(
|
||||
@ -42,7 +47,7 @@ class MyModelListView(ObjectListView):
|
||||
filterset = MyModelFilterSet
|
||||
```
|
||||
|
||||
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
|
||||
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
|
||||
|
||||
```python
|
||||
# api/views.py
|
||||
@ -62,7 +67,9 @@ The `ObjectListView` has a field called Quick Search. For Quick Search to work t
|
||||
```python
|
||||
from django.db.models import Q
|
||||
from netbox.filtersets import NetBoxModelFilterSet
|
||||
from utilities.filtersets import register_filterset
|
||||
|
||||
@register_filterset
|
||||
class MyFilterSet(NetBoxModelFilterSet):
|
||||
...
|
||||
def search(self, queryset, name, value):
|
||||
@ -90,7 +97,9 @@ This class filters `tags` using the `slug` field. For example:
|
||||
```python
|
||||
from django_filters import FilterSet
|
||||
from extras.filters import TagFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
|
||||
@register_filterset
|
||||
class MyModelFilterSet(FilterSet):
|
||||
tag = TagFilter()
|
||||
```
|
||||
@ -106,7 +115,9 @@ This class filters `tags` using the `id` field. For example:
|
||||
```python
|
||||
from django_filters import FilterSet
|
||||
from extras.filters import TagIDFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
|
||||
@register_filterset
|
||||
class MyModelFilterSet(FilterSet):
|
||||
tag_id = TagIDFilter()
|
||||
```
|
||||
|
||||
@ -11,6 +11,7 @@ from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from utilities.filtersets import register_filterset
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -29,6 +30,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -93,6 +95,7 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
@ -120,6 +123,7 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
@ -147,6 +151,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -154,6 +159,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
@ -265,6 +271,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -360,6 +367,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -367,6 +375,7 @@ class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -466,6 +475,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -473,6 +483,7 @@ class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='provider_network__provider',
|
||||
@ -529,6 +540,7 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@ -7,6 +7,7 @@ from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, Primary
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from users.models import User
|
||||
from utilities.filters import ContentTypeFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -20,6 +21,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DataSourceFilterSet(PrimaryModelFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=get_data_backend_choices,
|
||||
@ -48,6 +50,7 @@ class DataSourceFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search'
|
||||
@ -75,6 +78,7 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class JobFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -139,6 +143,7 @@ class JobFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectTypeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -164,6 +169,7 @@ class ObjectTypeFilterSet(BaseFilterSet):
|
||||
return queryset.filter(features__icontains=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectChangeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -203,6 +209,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@ -22,6 +22,7 @@ from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from utilities.filtersets import register_filterset
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
@ -84,6 +85,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -114,6 +116,7 @@ class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
@ -144,6 +147,7 @@ class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=SiteStatusChoices,
|
||||
@ -208,6 +212,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -287,6 +292,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
|
||||
return queryset
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -294,6 +300,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackTypeFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -332,6 +339,7 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -448,6 +456,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Rack.objects.all(),
|
||||
@ -537,6 +546,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -544,6 +554,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -687,6 +698,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.exclude(inventoryitemtemplates__isnull=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -703,6 +715,7 @@ class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
|
||||
profile_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ModuleTypeProfile.objects.all(),
|
||||
@ -819,6 +832,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -826,6 +840,7 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
|
||||
fields = ('id', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -833,6 +848,7 @@ class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDevi
|
||||
fields = ('id', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -840,6 +856,7 @@ class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
feed_leg = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
@ -855,6 +872,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
@ -879,6 +897,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@ -893,6 +912,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@ -904,6 +924,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -911,6 +932,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -918,6 +940,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItemTemplate.objects.all(),
|
||||
@ -961,6 +984,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceRoleFilterSet(NestedGroupModelFilterSet):
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
@ -995,6 +1019,7 @@ class DeviceRoleFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PlatformFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
@ -1052,6 +1077,7 @@ class PlatformFilterSet(NestedGroupModelFilterSet):
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceFilterSet(
|
||||
PrimaryModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
@ -1354,6 +1380,7 @@ class DeviceFilterSet(
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
@ -1403,6 +1430,7 @@ class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, Pri
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_type__manufacturer',
|
||||
@ -1691,6 +1719,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@ -1702,6 +1731,7 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
|
||||
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@ -1713,6 +1743,7 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi
|
||||
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerPortTypeChoices,
|
||||
@ -1727,6 +1758,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@ -1753,6 +1785,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class MACAddressFilterSet(PrimaryModelFilterSet):
|
||||
mac_address = MultiValueMACAddressFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
@ -1934,6 +1967,7 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
@ -2096,6 +2130,7 @@ class InterfaceFilterSet(
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@ -2113,6 +2148,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@ -2127,6 +2163,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ModuleBay.objects.all(),
|
||||
@ -2143,6 +2180,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceBayFilterSet(DeviceComponentFilterSet):
|
||||
installed_device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
@ -2160,6 +2198,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemFilterSet(DeviceComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
@ -2212,6 +2251,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -2219,6 +2259,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualChassisFilterSet(PrimaryModelFilterSet):
|
||||
master_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
@ -2295,6 +2336,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
termination_a_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
@ -2467,6 +2509,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
return self.filter_by_termination_object(queryset, CircuitTermination, value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@ -2475,6 +2518,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
|
||||
fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -2533,6 +2577,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@ -638,6 +638,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q')
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
|
||||
@ -12,6 +12,7 @@ from users.models import Group, User
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||
)
|
||||
from utilities.filtersets import register_filterset
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .choices import *
|
||||
from .filters import TagFilter, TagIDFilter
|
||||
@ -40,6 +41,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ScriptFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -62,6 +64,7 @@ class ScriptFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -91,6 +94,7 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -131,6 +135,7 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
return queryset.filter(event_types__overlap=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -180,6 +185,7 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -208,6 +214,7 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
|
||||
return queryset.filter(extra_choices__overlap=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -238,6 +245,7 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -276,6 +284,7 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -328,6 +337,7 @@ class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TableConfigFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -381,6 +391,7 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
|
||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||
|
||||
|
||||
@register_filterset
|
||||
class BookmarkFilterSet(BaseFilterSet):
|
||||
created = django_filters.DateTimeFilter()
|
||||
object_type_id = MultiValueNumberFilter()
|
||||
@ -401,6 +412,7 @@ class BookmarkFilterSet(BaseFilterSet):
|
||||
fields = ('id', 'object_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -444,6 +456,7 @@ class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -465,6 +478,7 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
@ -495,6 +509,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(comments__icontains=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -555,6 +570,7 @@ class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TaggedItemFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -590,6 +606,7 @@ class TaggedItemFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -620,6 +637,7 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -789,6 +807,7 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@ -287,6 +287,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = TableConfig
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import django_filters
|
||||
import netaddr
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
@ -10,15 +9,16 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
from netbox.filtersets import (
|
||||
ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, PrimaryModelFilterSet,
|
||||
)
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from utilities.filtersets import register_filterset
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from vpn.models import L2VPN
|
||||
from .choices import *
|
||||
@ -47,6 +47,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
import_target_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='import_targets',
|
||||
@ -85,6 +86,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
fields = ('id', 'name', 'rd', 'enforce_unique', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
importing_vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='importing_vrfs',
|
||||
@ -144,6 +146,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
fields = ('id', 'name', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -151,6 +154,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@ -198,6 +202,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
@ -223,6 +228,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
@ -285,6 +291,7 @@ class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -292,6 +299,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description', 'weight')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@ -458,6 +466,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='start_address',
|
||||
@ -550,6 +559,7 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
|
||||
return queryset.filter(q)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
@ -786,6 +796,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FHRPGroupFilterSet(PrimaryModelFilterSet):
|
||||
protocol = django_filters.MultipleChoiceFilter(
|
||||
choices=FHRPGroupProtocolChoices
|
||||
@ -833,6 +844,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(ip_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
interface_type = ContentTypeFilter()
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@ -887,6 +899,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
scope_type = ContentTypeFilter()
|
||||
region = django_filters.NumberFilter(
|
||||
@ -936,6 +949,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@ -1087,6 +1101,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -1103,6 +1118,7 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
|
||||
policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
@ -1134,6 +1150,7 @@ class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ServiceTemplateFilterSet(PrimaryModelFilterSet):
|
||||
port = NumericArrayFilter(
|
||||
field_name='ports',
|
||||
@ -1154,6 +1171,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
|
||||
parent_object_type = ContentTypeFilter()
|
||||
device = MultiValueCharFilter(
|
||||
|
||||
@ -152,6 +152,7 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
|
||||
elif isinstance(existing_filter, (
|
||||
django_filters.filters.CharFilter,
|
||||
django_filters.ChoiceFilter,
|
||||
django_filters.MultipleChoiceFilter,
|
||||
filters.MultiValueCharFilter,
|
||||
filters.MultiValueMACAddressFilter
|
||||
|
||||
@ -4,7 +4,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import *
|
||||
from users.models import Owner
|
||||
from utilities.forms.fields import DynamicModelChoiceField
|
||||
from utilities.forms.fields import DynamicModelChoiceField, QueryField
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin
|
||||
|
||||
__all__ = (
|
||||
@ -15,7 +16,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form):
|
||||
class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form):
|
||||
"""
|
||||
Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the
|
||||
corresponding FilterSet *must* provide a `q` filter.
|
||||
@ -27,7 +28,7 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
|
||||
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||
a selector widget
|
||||
"""
|
||||
q = forms.CharField(
|
||||
q = QueryField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
@ -26,6 +26,7 @@ registry = Registry({
|
||||
'data_backends': dict(),
|
||||
'denormalized_fields': collections.defaultdict(list),
|
||||
'event_types': dict(),
|
||||
'filtersets': dict(),
|
||||
'model_features': dict(),
|
||||
'models': collections.defaultdict(set),
|
||||
'plugins': dict(),
|
||||
|
||||
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
179
netbox/project-static/src/forms/filterModifiers.ts
Normal file
179
netbox/project-static/src/forms/filterModifiers.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
// Modifier codes for empty/null checking
|
||||
// These map to Django's 'empty' lookup: field__empty=true/false
|
||||
const MODIFIER_EMPTY_TRUE = 'empty_true';
|
||||
const MODIFIER_EMPTY_FALSE = 'empty_false';
|
||||
|
||||
/**
|
||||
* Initialize filter modifier functionality.
|
||||
*
|
||||
* Handles transformation of field names based on modifier selection
|
||||
* at form submission time using the FormData API.
|
||||
*/
|
||||
export function initFilterModifiers(): void {
|
||||
for (const form of getElements<HTMLFormElement>('form')) {
|
||||
const modifierSelects = form.querySelectorAll<HTMLSelectElement>('.modifier-select');
|
||||
if (modifierSelects.length === 0) continue;
|
||||
|
||||
initializeFromURL(form);
|
||||
|
||||
modifierSelects.forEach(select => {
|
||||
select.addEventListener('change', () => handleModifierChange(select));
|
||||
handleModifierChange(select);
|
||||
});
|
||||
|
||||
// Must use submit event for GET forms
|
||||
form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
handleFormDataTransform(form, formData);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && String(value).trim()) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Use getAttribute to avoid collision with form fields named 'action'
|
||||
const actionUrl = form.getAttribute('action') || form.action;
|
||||
window.location.href = `${actionUrl}?${params.toString()}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle modifier dropdown changes - disable/enable value input for empty lookups.
|
||||
*/
|
||||
function handleModifierChange(modifierSelect: HTMLSelectElement): void {
|
||||
const group = modifierSelect.closest('.filter-modifier-group');
|
||||
if (!group) return;
|
||||
|
||||
const wrapper = group.querySelector('.filter-value-container');
|
||||
if (!wrapper) return;
|
||||
|
||||
const valueInput = wrapper.querySelector<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>('input, select, textarea');
|
||||
|
||||
if (!valueInput) return;
|
||||
|
||||
const modifier = modifierSelect.value;
|
||||
|
||||
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
|
||||
valueInput.disabled = true;
|
||||
valueInput.value = '';
|
||||
const placeholder = modifierSelect.dataset.emptyPlaceholder || '(automatically set)';
|
||||
valueInput.setAttribute('placeholder', placeholder);
|
||||
} else {
|
||||
valueInput.disabled = false;
|
||||
valueInput.removeAttribute('placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform field names in FormData based on modifier selection.
|
||||
*/
|
||||
function handleFormDataTransform(form: HTMLFormElement, formData: FormData): void {
|
||||
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
|
||||
|
||||
for (const group of modifierGroups) {
|
||||
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
|
||||
const wrapper = group.querySelector('.filter-value-container');
|
||||
if (!wrapper) continue;
|
||||
|
||||
const valueInput = wrapper.querySelector<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>('input, select, textarea');
|
||||
|
||||
if (!modifierSelect || !valueInput) continue;
|
||||
|
||||
const currentName = valueInput.name;
|
||||
const modifier = modifierSelect.value;
|
||||
|
||||
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
|
||||
formData.delete(currentName);
|
||||
const boolValue = modifier === MODIFIER_EMPTY_TRUE ? 'true' : 'false';
|
||||
formData.set(`${currentName}__empty`, boolValue);
|
||||
} else {
|
||||
const values = formData.getAll(currentName);
|
||||
|
||||
if (values.length > 0 && values.some(v => String(v).trim())) {
|
||||
formData.delete(currentName);
|
||||
const newName = modifier === 'exact' ? currentName : `${currentName}__${modifier}`;
|
||||
|
||||
for (const value of values) {
|
||||
if (String(value).trim()) {
|
||||
formData.append(newName, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
formData.delete(currentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize form state from URL parameters.
|
||||
* Restores modifier selection and values from query string.
|
||||
*
|
||||
* Process:
|
||||
* 1. Parse URL parameters
|
||||
* 2. For each modifier group, check which lookup variant exists in URL
|
||||
* 3. Set modifier dropdown to match
|
||||
* 4. Populate value field with parameter value
|
||||
*/
|
||||
function initializeFromURL(form: HTMLFormElement): void {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
|
||||
|
||||
for (const group of modifierGroups) {
|
||||
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
|
||||
const wrapper = group.querySelector('.filter-value-container');
|
||||
if (!wrapper) continue;
|
||||
|
||||
const valueInput = wrapper.querySelector<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>('input, select, textarea');
|
||||
|
||||
if (!modifierSelect || !valueInput) continue;
|
||||
|
||||
const baseFieldName = valueInput.name;
|
||||
|
||||
// Special handling for empty - check if field__empty exists in URL
|
||||
const emptyParam = `${baseFieldName}__empty`;
|
||||
if (urlParams.has(emptyParam)) {
|
||||
const emptyValue = urlParams.get(emptyParam);
|
||||
const modifier = emptyValue === 'true' ? MODIFIER_EMPTY_TRUE : MODIFIER_EMPTY_FALSE;
|
||||
modifierSelect.value = modifier;
|
||||
continue; // Don't set value input for empty
|
||||
}
|
||||
|
||||
for (const option of modifierSelect.options) {
|
||||
const lookup = option.value;
|
||||
|
||||
// Skip empty_true/false as they're handled above
|
||||
if (lookup === MODIFIER_EMPTY_TRUE || lookup === MODIFIER_EMPTY_FALSE) continue;
|
||||
|
||||
const paramName = lookup === 'exact' ? baseFieldName : `${baseFieldName}__${lookup}`;
|
||||
|
||||
if (urlParams.has(paramName)) {
|
||||
modifierSelect.value = lookup;
|
||||
|
||||
if (valueInput instanceof HTMLSelectElement && valueInput.multiple) {
|
||||
const values = urlParams.getAll(paramName);
|
||||
for (const option of valueInput.options) {
|
||||
option.selected = values.includes(option.value);
|
||||
}
|
||||
} else {
|
||||
valueInput.value = urlParams.get(paramName) || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { initFormElements } from './elements';
|
||||
import { initFilterModifiers } from './filterModifiers';
|
||||
import { initSpeedSelector } from './speedSelector';
|
||||
|
||||
export function initForms(): void {
|
||||
for (const func of [initFormElements, initSpeedSelector]) {
|
||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,3 +32,11 @@ form.object-edit {
|
||||
border: 1px solid $red;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter modifier dropdown sizing
|
||||
.modifier-select {
|
||||
min-width: 10rem;
|
||||
max-width: 15rem;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ from netbox.filtersets import (
|
||||
NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
|
||||
)
|
||||
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
@ -24,6 +25,7 @@ __all__ = (
|
||||
# Contacts
|
||||
#
|
||||
|
||||
@register_filterset
|
||||
class ContactGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
@ -59,6 +61,7 @@ class ContactGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -66,6 +69,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactFilterSet(PrimaryModelFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
@ -100,6 +104,7 @@ class ContactFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -170,6 +175,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
|
||||
# Tenancy
|
||||
#
|
||||
|
||||
@register_filterset
|
||||
class TenantGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
@ -200,6 +206,7 @@ class TenantGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@ -8,6 +7,7 @@ from extras.models import NotificationGroup
|
||||
from netbox.filtersets import BaseFilterSet
|
||||
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
|
||||
from utilities.filters import ContentTypeFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
|
||||
__all__ = (
|
||||
'GroupFilterSet',
|
||||
@ -19,6 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class GroupFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -64,6 +65,7 @@ class GroupFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class UserFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -120,6 +122,7 @@ class UserFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TokenFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -181,6 +184,7 @@ class TokenFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -248,6 +252,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
return queryset.exclude(actions__contains=[action])
|
||||
|
||||
|
||||
@register_filterset
|
||||
class OwnerGroupFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@ -267,6 +272,7 @@ class OwnerGroupFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class OwnerFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
17
netbox/utilities/filtersets.py
Normal file
17
netbox/utilities/filtersets.py
Normal file
@ -0,0 +1,17 @@
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'register_filterset',
|
||||
)
|
||||
|
||||
|
||||
def register_filterset(filterset_class):
|
||||
"""
|
||||
Decorator for registering a FilterSet with the application registry.
|
||||
|
||||
Uses model identifier as key to match search index pattern.
|
||||
"""
|
||||
model = filterset_class._meta.model
|
||||
label = f'{model._meta.app_label}.{model._meta.model_name}'
|
||||
registry['filtersets'][label] = filterset_class
|
||||
return filterset_class
|
||||
@ -17,11 +17,20 @@ __all__ = (
|
||||
'JSONField',
|
||||
'LaxURLField',
|
||||
'MACAddressField',
|
||||
'QueryField',
|
||||
'SlugField',
|
||||
'TagFilterField',
|
||||
)
|
||||
|
||||
|
||||
class QueryField(forms.CharField):
|
||||
"""
|
||||
A CharField subclass used for global search/query fields in filter forms.
|
||||
This field type signals to FilterModifierMixin to skip enhancement with lookup modifiers.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CommentField(forms.CharField):
|
||||
"""
|
||||
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
|
||||
|
||||
@ -4,7 +4,8 @@ from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
from utilities.forms.fields import QueryField
|
||||
from utilities.forms.mixins import BackgroundJobMixin, FilterModifierMixin
|
||||
|
||||
__all__ = (
|
||||
'BulkDeleteForm',
|
||||
@ -140,11 +141,11 @@ class CSVModelForm(forms.ModelForm):
|
||||
return super().clean()
|
||||
|
||||
|
||||
class FilterForm(forms.Form):
|
||||
class FilterForm(FilterModifierMixin, forms.Form):
|
||||
"""
|
||||
Base Form class for FilterSet forms.
|
||||
"""
|
||||
q = forms.CharField(
|
||||
q = QueryField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
@ -5,13 +5,100 @@ from django import forms
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from utilities.forms.fields import ColorField, QueryField, TagFilterField
|
||||
from utilities.forms.widgets import FilterModifierWidget
|
||||
from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE
|
||||
|
||||
__all__ = (
|
||||
'BackgroundJobMixin',
|
||||
'CheckLastUpdatedMixin',
|
||||
'DistanceValidationMixin',
|
||||
'FilterModifierMixin',
|
||||
'FORM_FIELD_LOOKUPS',
|
||||
)
|
||||
|
||||
|
||||
# Mapping of form field types to their supported lookups
|
||||
FORM_FIELD_LOOKUPS = {
|
||||
QueryField: [],
|
||||
forms.BooleanField: [],
|
||||
forms.NullBooleanField: [],
|
||||
forms.CharField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
('ic', _('contains')),
|
||||
('isw', _('starts with')),
|
||||
('iew', _('ends with')),
|
||||
('ie', _('equals (case-insensitive)')),
|
||||
('regex', _('matches pattern')),
|
||||
('iregex', _('matches pattern (case-insensitive)')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.IntegerField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
('gt', _('greater than')),
|
||||
('gte', _('at least')),
|
||||
('lt', _('less than')),
|
||||
('lte', _('at most')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.DecimalField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
('gt', _('greater than')),
|
||||
('gte', _('at least')),
|
||||
('lt', _('less than')),
|
||||
('lte', _('at most')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.DateField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
('gt', _('after')),
|
||||
('gte', _('on or after')),
|
||||
('lt', _('before')),
|
||||
('lte', _('on or before')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.ModelChoiceField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
ColorField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
TagFilterField: [
|
||||
('exact', _('has these tags')),
|
||||
('n', _('does not have these tags')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.ChoiceField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
forms.MultipleChoiceField: [
|
||||
('exact', _('is')),
|
||||
('n', _('is not')),
|
||||
(MODIFIER_EMPTY_TRUE, _('is empty')),
|
||||
(MODIFIER_EMPTY_FALSE, _('is not empty')),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BackgroundJobMixin(forms.Form):
|
||||
background_job = forms.BooleanField(
|
||||
label=_('Background job'),
|
||||
@ -75,3 +162,68 @@ class DistanceValidationMixin(forms.Form):
|
||||
MaxValueValidator(Decimal(100000)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FilterModifierMixin:
|
||||
"""
|
||||
Mixin that enhances filter form fields with lookup modifier dropdowns.
|
||||
|
||||
Automatically detects fields that could benefit from multiple lookup options
|
||||
and wraps their widgets with FilterModifierWidget.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._enhance_fields_with_modifiers()
|
||||
|
||||
def _enhance_fields_with_modifiers(self):
|
||||
"""Wrap compatible field widgets with FilterModifierWidget."""
|
||||
|
||||
model = getattr(self, 'model', None)
|
||||
if model is None and hasattr(self, '_meta'):
|
||||
model = getattr(self._meta, 'model', None)
|
||||
|
||||
filterset_class = None
|
||||
if model:
|
||||
key = f'{model._meta.app_label}.{model._meta.model_name}'
|
||||
filterset_class = registry['filtersets'].get(key)
|
||||
|
||||
filterset = filterset_class() if filterset_class else None
|
||||
|
||||
for field_name, field in self.fields.items():
|
||||
lookups = self._get_lookup_choices(field)
|
||||
|
||||
if filterset:
|
||||
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
|
||||
|
||||
if len(lookups) > 1:
|
||||
field.widget = FilterModifierWidget(
|
||||
widget=field.widget,
|
||||
lookups=lookups
|
||||
)
|
||||
|
||||
def _get_lookup_choices(self, field):
|
||||
"""Determine the available lookup choices for a given field.
|
||||
|
||||
Returns an empty list for fields that should not be enhanced.
|
||||
"""
|
||||
for field_class in field.__class__.__mro__:
|
||||
if field_lookups := FORM_FIELD_LOOKUPS.get(field_class):
|
||||
return field_lookups
|
||||
|
||||
return []
|
||||
|
||||
def _verify_lookups_with_filterset(self, field_name, lookups, filterset):
|
||||
"""Verify which lookups are actually supported by the FilterSet."""
|
||||
verified_lookups = []
|
||||
|
||||
for lookup_code, lookup_label in lookups:
|
||||
if lookup_code in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE):
|
||||
filter_key = f'{field_name}__empty'
|
||||
else:
|
||||
filter_key = f'{field_name}__{lookup_code}' if lookup_code != 'exact' else field_name
|
||||
|
||||
if filter_key in filterset.filters:
|
||||
verified_lookups.append((lookup_code, lookup_label))
|
||||
|
||||
return verified_lookups
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from .apiselect import *
|
||||
from .datetime import *
|
||||
from .misc import *
|
||||
from .modifiers import *
|
||||
from .select import *
|
||||
|
||||
113
netbox/utilities/forms/widgets/modifiers.py
Normal file
113
netbox/utilities/forms/widgets/modifiers.py
Normal file
@ -0,0 +1,113 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
__all__ = (
|
||||
'FilterModifierWidget',
|
||||
'MODIFIER_EMPTY_FALSE',
|
||||
'MODIFIER_EMPTY_TRUE',
|
||||
)
|
||||
|
||||
# Modifier codes for empty/null checking
|
||||
# These map to Django's 'empty' lookup: field__empty=true/false
|
||||
MODIFIER_EMPTY_TRUE = 'empty_true'
|
||||
MODIFIER_EMPTY_FALSE = 'empty_false'
|
||||
|
||||
|
||||
class FilterModifierWidget(forms.Widget):
|
||||
"""
|
||||
Wraps an existing widget to add a modifier dropdown for filter lookups.
|
||||
|
||||
The original widget's semantics (name, id, attributes) are preserved.
|
||||
The modifier dropdown controls which lookup type is used (exact, contains, etc.).
|
||||
"""
|
||||
template_name = 'widgets/filter_modifier.html'
|
||||
|
||||
def __init__(self, widget, lookups, attrs=None):
|
||||
"""
|
||||
Args:
|
||||
widget: The widget being wrapped (e.g., TextInput, NumberInput)
|
||||
lookups: List of (lookup_code, label) tuples (e.g., [('exact', 'Is'), ('ic', 'Contains')])
|
||||
attrs: Additional widget attributes
|
||||
"""
|
||||
self.original_widget = widget
|
||||
self.lookups = lookups
|
||||
super().__init__(attrs or getattr(widget, 'attrs', {}))
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""
|
||||
Extract value from data, checking all possible lookup variants.
|
||||
|
||||
When form redisplays after validation error, the data may contain
|
||||
serial__ic=test but the field is named serial. This method searches
|
||||
all lookup variants to find the value.
|
||||
|
||||
Returns:
|
||||
Just the value string for form validation. The modifier is reconstructed
|
||||
during rendering from the query parameter names.
|
||||
"""
|
||||
# Special handling for empty - check if field__empty exists
|
||||
empty_param = f"{name}__empty"
|
||||
if empty_param in data:
|
||||
# Return the boolean value for empty lookup
|
||||
return data.get(empty_param)
|
||||
|
||||
# Try exact field name first
|
||||
value = self.original_widget.value_from_datadict(data, files, name)
|
||||
|
||||
# If not found, check all modifier variants
|
||||
# Note: SelectMultiple returns [] (empty list) when not found, not None
|
||||
if value is None or (isinstance(value, list) and len(value) == 0):
|
||||
for lookup, _ in self.lookups:
|
||||
if lookup == 'exact':
|
||||
continue # Already checked above
|
||||
# Skip empty_true/false variants - they're handled above
|
||||
if lookup in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE):
|
||||
continue
|
||||
lookup_name = f"{name}__{lookup}"
|
||||
test_value = self.original_widget.value_from_datadict(data, files, lookup_name)
|
||||
if test_value is not None:
|
||||
value = test_value
|
||||
break
|
||||
|
||||
# Return None if no value found (prevents field appearing in changed_data)
|
||||
# Handle all widget empty value representations
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
if isinstance(value, (list, tuple)) and len(value) == 0:
|
||||
return None
|
||||
|
||||
# Return just the value for form validation
|
||||
return value
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
"""
|
||||
Build context for template rendering.
|
||||
|
||||
Includes both the original widget's context and our modifier-specific data.
|
||||
Note: value is now just a simple value (string/int/etc), not a dict.
|
||||
The JavaScript initializeFromURL() will set the correct modifier dropdown
|
||||
value based on URL parameters.
|
||||
"""
|
||||
# Propagate any attrs set on the wrapper (like data-url from get_bound_field)
|
||||
# to the original widget before rendering
|
||||
self.original_widget.attrs.update(self.attrs)
|
||||
|
||||
# Get context from the original widget
|
||||
original_context = self.original_widget.get_context(name, value, attrs)
|
||||
|
||||
# Build our wrapper context
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['original_widget'] = original_context['widget']
|
||||
context['widget']['lookups'] = self.lookups
|
||||
context['widget']['field_name'] = name
|
||||
|
||||
# Default to 'exact' - JavaScript will update based on URL params
|
||||
context['widget']['current_modifier'] = 'exact'
|
||||
context['widget']['current_value'] = value or ''
|
||||
|
||||
# Translatable placeholder for empty lookups
|
||||
context['widget']['empty_placeholder'] = _('(automatically set)')
|
||||
|
||||
return context
|
||||
18
netbox/utilities/templates/widgets/filter_modifier.html
Normal file
18
netbox/utilities/templates/widgets/filter_modifier.html
Normal file
@ -0,0 +1,18 @@
|
||||
<div class="d-flex filter-modifier-group">
|
||||
{% if widget.lookups %}
|
||||
{# Modifier dropdown - NO name attribute, just a UI control #}
|
||||
<select class="form-select modifier-select"
|
||||
data-field="{{ widget.field_name }}"
|
||||
data-empty-placeholder="{{ widget.empty_placeholder }}"
|
||||
aria-label="Modifier">
|
||||
{% for lookup, label in widget.lookups %}
|
||||
<option value="{{ lookup }}"{% if widget.current_modifier == lookup %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
|
||||
{# Original widget - rendered exactly as it would be without our wrapper #}
|
||||
<div class="ms-2 flex-grow-1 filter-value-container">
|
||||
{% include widget.original_widget.template_name with widget=widget.original_widget %}
|
||||
</div>
|
||||
</div>
|
||||
@ -5,9 +5,11 @@ from urllib.parse import quote
|
||||
from django import template
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ObjectType
|
||||
from utilities.forms import get_selected_values, TableConfigForm
|
||||
from utilities.forms.mixins import FORM_FIELD_LOOKUPS
|
||||
from utilities.views import get_viewname, get_action_url
|
||||
from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT
|
||||
|
||||
@ -418,7 +420,20 @@ def applied_filters(context, model, form, query_params):
|
||||
continue
|
||||
|
||||
querydict = query_params.copy()
|
||||
if filter_name not in querydict:
|
||||
|
||||
# Check if this is a modifier-enhanced field
|
||||
# Field may be in querydict as field__lookup instead of field
|
||||
param_name = None
|
||||
if filter_name in querydict:
|
||||
param_name = filter_name
|
||||
else:
|
||||
# Check for modifier variants (field__ic, field__isw, etc.)
|
||||
for key in querydict.keys():
|
||||
if key.startswith(f'{filter_name}__'):
|
||||
param_name = key
|
||||
break
|
||||
|
||||
if param_name is None:
|
||||
continue
|
||||
|
||||
# Skip saved filters, as they're displayed alongside the quick search widget
|
||||
@ -426,14 +441,46 @@ def applied_filters(context, model, form, query_params):
|
||||
continue
|
||||
|
||||
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
|
||||
querydict.pop(filter_name)
|
||||
querydict.pop(param_name)
|
||||
|
||||
# Extract modifier from parameter name (e.g., "serial__ic" → "ic")
|
||||
if '__' in param_name:
|
||||
modifier = param_name.split('__', 1)[1]
|
||||
else:
|
||||
modifier = 'exact'
|
||||
|
||||
# Get display value
|
||||
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
|
||||
|
||||
# Get the correct lookup label for this field's type
|
||||
lookup_label = None
|
||||
if modifier != 'exact':
|
||||
field = form.fields[filter_name]
|
||||
for field_class in field.__class__.__mro__:
|
||||
if field_lookups := FORM_FIELD_LOOKUPS.get(field_class):
|
||||
for lookup_code, label in field_lookups:
|
||||
if lookup_code == modifier:
|
||||
lookup_label = label
|
||||
break
|
||||
if lookup_label:
|
||||
break
|
||||
|
||||
# Special handling for empty lookup (boolean value)
|
||||
if modifier == 'empty':
|
||||
if display_value.lower() in ('true', '1'):
|
||||
link_text = f'{bound_field.label} {_("is empty")}'
|
||||
else:
|
||||
link_text = f'{bound_field.label} {_("is not empty")}'
|
||||
elif lookup_label:
|
||||
link_text = f'{bound_field.label} {lookup_label}: {display_value}'
|
||||
else:
|
||||
link_text = f'{bound_field.label}: {display_value}'
|
||||
|
||||
applied_filters.append({
|
||||
'name': filter_name,
|
||||
'value': form.cleaned_data[filter_name],
|
||||
'name': param_name, # Use actual param name for removal link
|
||||
'value': form.cleaned_data.get(filter_name),
|
||||
'link_url': f'?{querydict.urlencode()}',
|
||||
'link_text': f'{bound_field.label}: {display_value}',
|
||||
'link_text': link_text,
|
||||
})
|
||||
|
||||
save_link = None
|
||||
|
||||
293
netbox/utilities/tests/test_filter_modifiers.py
Normal file
293
netbox/utilities/tests/test_filter_modifiers.py
Normal file
@ -0,0 +1,293 @@
|
||||
from django import forms
|
||||
from django.db import models
|
||||
from django.http import QueryDict
|
||||
from django.template import Context
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
import dcim.filtersets # noqa: F401 - Import to register Device filterset
|
||||
from dcim.forms.filtersets import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from netbox.filtersets import BaseFilterSet
|
||||
from utilities.filtersets import register_filterset
|
||||
from users.models import User
|
||||
from utilities.forms.fields import TagFilterField
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from utilities.forms.widgets import FilterModifierWidget
|
||||
from utilities.templatetags.helpers import applied_filters
|
||||
|
||||
|
||||
# Test model for FilterModifierMixin tests
|
||||
class TestModel(models.Model):
|
||||
"""Dummy model for testing filter modifiers."""
|
||||
char_field = models.CharField(max_length=100, blank=True)
|
||||
integer_field = models.IntegerField(null=True, blank=True)
|
||||
decimal_field = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||
date_field = models.DateField(null=True, blank=True)
|
||||
boolean_field = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
app_label = 'utilities'
|
||||
managed = False # Don't create actual database table
|
||||
|
||||
|
||||
# Test filterset using BaseFilterSet to automatically generate lookups
|
||||
@register_filterset
|
||||
class TestFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = TestModel
|
||||
fields = ['char_field', 'integer_field', 'decimal_field', 'date_field', 'boolean_field']
|
||||
|
||||
|
||||
class FilterModifierWidgetTest(TestCase):
|
||||
"""Tests for FilterModifierWidget value extraction and rendering."""
|
||||
|
||||
def test_value_from_datadict_finds_value_in_lookup_variant(self):
|
||||
"""
|
||||
Widget should find value from serial__ic when field is named serial.
|
||||
This is critical for form redisplay after validation errors.
|
||||
"""
|
||||
widget = FilterModifierWidget(
|
||||
widget=forms.TextInput(),
|
||||
lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
|
||||
)
|
||||
data = QueryDict('serial__ic=test123')
|
||||
|
||||
result = widget.value_from_datadict(data, {}, 'serial')
|
||||
|
||||
self.assertEqual(result, 'test123')
|
||||
|
||||
def test_value_from_datadict_handles_exact_match(self):
|
||||
"""Widget should detect exact match when field name has no modifier."""
|
||||
widget = FilterModifierWidget(
|
||||
widget=forms.TextInput(),
|
||||
lookups=[('exact', 'Is'), ('ic', 'Contains')]
|
||||
)
|
||||
data = QueryDict('serial=test456')
|
||||
|
||||
result = widget.value_from_datadict(data, {}, 'serial')
|
||||
|
||||
self.assertEqual(result, 'test456')
|
||||
|
||||
def test_value_from_datadict_returns_none_when_no_value(self):
|
||||
"""Widget should return None when no data present to avoid appearing in changed_data."""
|
||||
widget = FilterModifierWidget(
|
||||
widget=forms.TextInput(),
|
||||
lookups=[('exact', 'Is'), ('ic', 'Contains')]
|
||||
)
|
||||
data = QueryDict('')
|
||||
|
||||
result = widget.value_from_datadict(data, {}, 'serial')
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_context_includes_original_widget_and_lookups(self):
|
||||
"""Widget context should include original widget context and lookup choices."""
|
||||
widget = FilterModifierWidget(
|
||||
widget=forms.TextInput(),
|
||||
lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
|
||||
)
|
||||
value = 'test'
|
||||
|
||||
context = widget.get_context('serial', value, {})
|
||||
|
||||
self.assertIn('original_widget', context['widget'])
|
||||
self.assertEqual(
|
||||
context['widget']['lookups'],
|
||||
[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
|
||||
)
|
||||
self.assertEqual(context['widget']['field_name'], 'serial')
|
||||
self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL
|
||||
self.assertEqual(context['widget']['current_value'], 'test')
|
||||
|
||||
def test_widget_renders_modifier_dropdown_and_input(self):
|
||||
"""Widget should render modifier dropdown alongside original input."""
|
||||
widget = FilterModifierWidget(
|
||||
widget=forms.TextInput(),
|
||||
lookups=[('exact', 'Is'), ('ic', 'Contains')]
|
||||
)
|
||||
|
||||
html = widget.render('serial', 'test', {})
|
||||
|
||||
# Should contain modifier dropdown
|
||||
self.assertIn('class="form-select modifier-select"', html)
|
||||
self.assertIn('data-field="serial"', html)
|
||||
self.assertIn('<option value="exact" selected>Is</option>', html)
|
||||
self.assertIn('<option value="ic">Contains</option>', html)
|
||||
|
||||
# Should contain original input
|
||||
self.assertIn('type="text"', html)
|
||||
self.assertIn('name="serial"', html)
|
||||
self.assertIn('value="test"', html)
|
||||
|
||||
|
||||
class FilterModifierMixinTest(TestCase):
|
||||
"""Tests for FilterModifierMixin form field enhancement."""
|
||||
|
||||
def test_mixin_enhances_char_field_with_modifiers(self):
|
||||
"""CharField should be enhanced with contains/starts/ends modifiers."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
char_field = forms.CharField(required=False)
|
||||
model = TestModel
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['char_field'].widget, FilterModifierWidget)
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['char_field'].widget.lookups]
|
||||
expected_lookups = ['exact', 'n', 'ic', 'isw', 'iew', 'ie', 'regex', 'iregex', 'empty_true', 'empty_false']
|
||||
self.assertEqual(lookup_codes, expected_lookups)
|
||||
|
||||
def test_mixin_skips_boolean_fields(self):
|
||||
"""Boolean fields should not be enhanced."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
boolean_field = forms.BooleanField(required=False)
|
||||
model = TestModel
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertNotIsInstance(form.fields['boolean_field'].widget, FilterModifierWidget)
|
||||
|
||||
def test_mixin_enhances_tag_filter_field(self):
|
||||
"""TagFilterField should be enhanced even though it's a MultipleChoiceField."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
tag = TagFilterField(Device)
|
||||
model = Device
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
|
||||
tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
|
||||
# Device filterset has tag and tag__n but not tag__empty
|
||||
expected_lookups = ['exact', 'n']
|
||||
self.assertEqual(tag_lookups, expected_lookups)
|
||||
|
||||
def test_mixin_enhances_integer_field(self):
|
||||
"""IntegerField should be enhanced with comparison modifiers."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
integer_field = forms.IntegerField(required=False)
|
||||
model = TestModel
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['integer_field'].widget, FilterModifierWidget)
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['integer_field'].widget.lookups]
|
||||
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
|
||||
self.assertEqual(lookup_codes, expected_lookups)
|
||||
|
||||
def test_mixin_enhances_decimal_field(self):
|
||||
"""DecimalField should be enhanced with comparison modifiers."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
decimal_field = forms.DecimalField(required=False)
|
||||
model = TestModel
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['decimal_field'].widget, FilterModifierWidget)
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['decimal_field'].widget.lookups]
|
||||
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
|
||||
self.assertEqual(lookup_codes, expected_lookups)
|
||||
|
||||
def test_mixin_enhances_date_field(self):
|
||||
"""DateField should be enhanced with date-appropriate modifiers."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
date_field = forms.DateField(required=False)
|
||||
model = TestModel
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['date_field'].widget, FilterModifierWidget)
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['date_field'].widget.lookups]
|
||||
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
|
||||
self.assertEqual(lookup_codes, expected_lookups)
|
||||
|
||||
|
||||
class ExtendedLookupFilterPillsTest(TestCase):
|
||||
"""Tests for filter pill rendering of extended lookups."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create(username='test_user')
|
||||
|
||||
def test_negation_lookup_filter_pill(self):
|
||||
"""Filter pill should show 'is not' for negation lookup."""
|
||||
query_params = QueryDict('serial__n=ABC123')
|
||||
form = DeviceFilterForm(query_params)
|
||||
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Device, form, query_params)
|
||||
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
self.assertIn('is not', filter_pill['link_text'].lower())
|
||||
self.assertIn('ABC123', filter_pill['link_text'])
|
||||
|
||||
def test_regex_lookup_filter_pill(self):
|
||||
"""Filter pill should show 'matches pattern' for regex lookup."""
|
||||
query_params = QueryDict('serial__regex=^ABC.*')
|
||||
form = DeviceFilterForm(query_params)
|
||||
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Device, form, query_params)
|
||||
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
self.assertIn('matches pattern', filter_pill['link_text'].lower())
|
||||
|
||||
def test_exact_lookup_filter_pill(self):
|
||||
"""Filter pill should show field label and value without lookup modifier for exact match."""
|
||||
query_params = QueryDict('serial=ABC123')
|
||||
form = DeviceFilterForm(query_params)
|
||||
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Device, form, query_params)
|
||||
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
# Should not contain lookup modifier text
|
||||
self.assertNotIn('is not', filter_pill['link_text'].lower())
|
||||
self.assertNotIn('matches pattern', filter_pill['link_text'].lower())
|
||||
self.assertNotIn('contains', filter_pill['link_text'].lower())
|
||||
# Should contain field label and value
|
||||
self.assertIn('Serial', filter_pill['link_text'])
|
||||
self.assertIn('ABC123', filter_pill['link_text'])
|
||||
|
||||
|
||||
class EmptyLookupTest(TestCase):
|
||||
"""Tests for empty (is empty/not empty) lookup support."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create(username='test_user')
|
||||
|
||||
def test_empty_true_appears_in_filter_pills(self):
|
||||
"""Filter pill should show 'Is Empty' for empty=true."""
|
||||
query_params = QueryDict('serial__empty=true')
|
||||
form = DeviceFilterForm(query_params)
|
||||
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Device, form, query_params)
|
||||
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
self.assertIn('empty', filter_pill['link_text'].lower())
|
||||
|
||||
def test_empty_false_appears_in_filter_pills(self):
|
||||
"""Filter pill should show 'Is Not Empty' for empty=false."""
|
||||
query_params = QueryDict('serial__empty=false')
|
||||
form = DeviceFilterForm(query_params)
|
||||
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Device, form, query_params)
|
||||
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
self.assertIn('not empty', filter_pill['link_text'].lower())
|
||||
@ -11,9 +11,9 @@ from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
|
||||
from users.filterset_mixins import OwnerFilterMixin
|
||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -27,6 +27,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ClusterTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -34,6 +35,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -41,6 +43,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet):
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
@ -81,6 +84,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet,
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualMachineFilterSet(
|
||||
PrimaryModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
@ -241,6 +245,7 @@ class VirtualMachineFilterSet(
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine__cluster',
|
||||
@ -303,6 +308,7 @@ class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxMod
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualDiskFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine',
|
||||
|
||||
@ -8,6 +8,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import *
|
||||
@ -26,6 +27,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@ -33,6 +35,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=TunnelStatusChoices
|
||||
@ -75,6 +78,7 @@ class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelTerminationFilterSet(NetBoxModelFilterSet):
|
||||
tunnel_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tunnel',
|
||||
@ -124,6 +128,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
|
||||
fields = ('id', 'termination_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IKEProposalFilterSet(PrimaryModelFilterSet):
|
||||
ike_policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ike_policies',
|
||||
@ -163,6 +168,7 @@ class IKEProposalFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IKEPolicyFilterSet(PrimaryModelFilterSet):
|
||||
version = django_filters.MultipleChoiceFilter(
|
||||
choices=IKEVersionChoices
|
||||
@ -194,6 +200,7 @@ class IKEPolicyFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecProposalFilterSet(PrimaryModelFilterSet):
|
||||
ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ipsec_policies',
|
||||
@ -227,6 +234,7 @@ class IPSecProposalFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecPolicyFilterSet(PrimaryModelFilterSet):
|
||||
pfs_group = django_filters.MultipleChoiceFilter(
|
||||
choices=DHGroupChoices
|
||||
@ -255,6 +263,7 @@ class IPSecPolicyFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecProfileFilterSet(PrimaryModelFilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
choices=IPSecModeChoices
|
||||
@ -294,6 +303,7 @@ class IPSecProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=L2VPNTypeChoices,
|
||||
@ -340,6 +350,7 @@ class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=L2VPN.objects.all(),
|
||||
|
||||
@ -245,7 +245,7 @@ class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFil
|
||||
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
model = L2VPNTermination
|
||||
fieldsets = (
|
||||
FieldSet('filter_id', 'l2vpn_id'),
|
||||
FieldSet('filter_id', 'tag', 'l2vpn_id'),
|
||||
FieldSet(
|
||||
'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
|
||||
name=_('Assigned Object')
|
||||
@ -303,3 +303,4 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.choices import LinkStatusChoices
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from dcim.choices import LinkStatusChoices
|
||||
from dcim.models import Interface
|
||||
from ipam.models import VLAN
|
||||
from netbox.filtersets import NestedGroupModelFilterSet, PrimaryModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -18,6 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=WirelessLANGroup.objects.all()
|
||||
@ -44,6 +46,7 @@ class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
@ -87,6 +90,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilter
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WirelessLinkFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
interface_a_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Interface.objects.all()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user