mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-09 13:22:18 -06:00
Compare commits
1 Commits
55fa832a2f
...
353e627bf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353e627bf2 |
@@ -20,10 +20,6 @@ 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,17 +6,12 @@ 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=(
|
||||
@@ -55,7 +50,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
|
||||
@@ -75,9 +70,7 @@ 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):
|
||||
@@ -105,9 +98,7 @@ 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()
|
||||
```
|
||||
@@ -123,9 +114,7 @@ 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,7 +11,6 @@ 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 *
|
||||
|
||||
@@ -30,7 +29,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -95,7 +93,6 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
@@ -123,7 +120,6 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
@@ -151,7 +147,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -159,7 +154,6 @@ 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(),
|
||||
@@ -271,7 +265,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -367,7 +360,6 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -375,7 +367,6 @@ class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -475,7 +466,6 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -483,7 +473,6 @@ 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',
|
||||
@@ -540,7 +529,6 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 *
|
||||
|
||||
@@ -21,7 +20,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DataSourceFilterSet(PrimaryModelFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=get_data_backend_choices,
|
||||
@@ -50,7 +48,6 @@ class DataSourceFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search'
|
||||
@@ -78,7 +75,6 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class JobFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -143,7 +139,6 @@ class JobFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectTypeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -169,7 +164,6 @@ class ObjectTypeFilterSet(BaseFilterSet):
|
||||
return queryset.filter(features__icontains=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectChangeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -209,7 +203,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -85,7 +84,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -116,7 +114,6 @@ class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
@@ -147,7 +144,6 @@ class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -212,7 +208,6 @@ 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(),
|
||||
@@ -292,7 +287,6 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
|
||||
return queryset
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -300,7 +294,6 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackTypeFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -339,7 +332,6 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -456,7 +448,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -546,7 +537,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -554,7 +544,6 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -698,7 +687,6 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.exclude(inventoryitemtemplates__isnull=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -715,7 +703,6 @@ class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
|
||||
profile_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ModuleTypeProfile.objects.all(),
|
||||
@@ -832,7 +819,6 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -840,7 +826,6 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
|
||||
fields = ('id', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -848,7 +833,6 @@ class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDevi
|
||||
fields = ('id', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -856,7 +840,6 @@ 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,
|
||||
@@ -872,7 +855,6 @@ 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,
|
||||
@@ -897,7 +879,6 @@ 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,
|
||||
@@ -912,7 +893,6 @@ 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,
|
||||
@@ -924,7 +904,6 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -932,7 +911,6 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -940,7 +918,6 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItemTemplate.objects.all(),
|
||||
@@ -984,7 +961,6 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceRoleFilterSet(NestedGroupModelFilterSet):
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
@@ -1019,7 +995,6 @@ 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(),
|
||||
@@ -1077,7 +1052,6 @@ class PlatformFilterSet(NestedGroupModelFilterSet):
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceFilterSet(
|
||||
PrimaryModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
@@ -1380,7 +1354,6 @@ class DeviceFilterSet(
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
@@ -1430,7 +1403,6 @@ class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, Pri
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleFilterSet(PrimaryModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_type__manufacturer',
|
||||
@@ -1719,7 +1691,6 @@ 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,
|
||||
@@ -1731,7 +1702,6 @@ 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,
|
||||
@@ -1743,7 +1713,6 @@ 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,
|
||||
@@ -1758,7 +1727,6 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -1785,7 +1753,6 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class MACAddressFilterSet(PrimaryModelFilterSet):
|
||||
mac_address = MultiValueMACAddressFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
@@ -1967,7 +1934,6 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
@@ -2130,7 +2096,6 @@ class InterfaceFilterSet(
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@@ -2148,7 +2113,6 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@@ -2163,7 +2127,6 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ModuleBay.objects.all(),
|
||||
@@ -2180,7 +2143,6 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class DeviceBayFilterSet(DeviceComponentFilterSet):
|
||||
installed_device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -2198,7 +2160,6 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemFilterSet(DeviceComponentFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
@@ -2251,7 +2212,6 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -2259,7 +2219,6 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualChassisFilterSet(PrimaryModelFilterSet):
|
||||
master_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -2336,7 +2295,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
termination_a_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
@@ -2509,7 +2467,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
return self.filter_by_termination_object(queryset, CircuitTermination, value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@@ -2518,7 +2475,6 @@ 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(),
|
||||
@@ -2577,7 +2533,6 @@ 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,7 +638,6 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q')
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -41,7 +40,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ScriptFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -64,7 +62,6 @@ class ScriptFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -94,7 +91,6 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -135,7 +131,6 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
return queryset.filter(event_types__overlap=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -185,7 +180,6 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -214,7 +208,6 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet
|
||||
return queryset.filter(extra_choices__overlap=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -245,7 +238,6 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -284,7 +276,6 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -337,7 +328,6 @@ 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',
|
||||
@@ -391,7 +381,6 @@ 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()
|
||||
@@ -412,7 +401,6 @@ class BookmarkFilterSet(BaseFilterSet):
|
||||
fields = ('id', 'object_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -456,7 +444,6 @@ class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -478,7 +465,6 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
@@ -509,7 +495,6 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(comments__icontains=value)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -570,7 +555,6 @@ class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TaggedItemFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -606,7 +590,6 @@ class TaggedItemFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -637,7 +620,6 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -807,7 +789,6 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -287,7 +287,6 @@ 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,5 +1,6 @@
|
||||
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
|
||||
@@ -9,16 +10,15 @@ 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,7 +47,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
import_target_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='import_targets',
|
||||
@@ -86,7 +85,6 @@ 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',
|
||||
@@ -146,7 +144,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
fields = ('id', 'name', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -154,7 +151,6 @@ class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@@ -202,7 +198,6 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
|
||||
return queryset.none()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
@@ -228,7 +223,6 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RIR.objects.all(),
|
||||
@@ -291,7 +285,6 @@ class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -299,7 +292,6 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description', 'weight')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@@ -466,7 +458,6 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='start_address',
|
||||
@@ -559,7 +550,6 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
|
||||
return queryset.filter(q)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
@@ -796,7 +786,6 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FHRPGroupFilterSet(PrimaryModelFilterSet):
|
||||
protocol = django_filters.MultipleChoiceFilter(
|
||||
choices=FHRPGroupProtocolChoices
|
||||
@@ -844,7 +833,6 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(ip_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
interface_type = ContentTypeFilter()
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -899,7 +887,6 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
scope_type = ContentTypeFilter()
|
||||
region = django_filters.NumberFilter(
|
||||
@@ -949,7 +936,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1101,7 +1087,6 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -1118,7 +1103,6 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
|
||||
policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
@@ -1150,7 +1134,6 @@ class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ServiceTemplateFilterSet(PrimaryModelFilterSet):
|
||||
port = NumericArrayFilter(
|
||||
field_name='ports',
|
||||
@@ -1171,7 +1154,6 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
|
||||
parent_object_type = ContentTypeFilter()
|
||||
device = MultiValueCharFilter(
|
||||
|
||||
@@ -152,7 +152,6 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
|
||||
elif isinstance(existing_filter, (
|
||||
django_filters.filters.CharFilter,
|
||||
django_filters.ChoiceFilter,
|
||||
django_filters.MultipleChoiceFilter,
|
||||
filters.MultiValueCharFilter,
|
||||
filters.MultiValueMACAddressFilter
|
||||
|
||||
@@ -4,8 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import *
|
||||
from users.models import Owner
|
||||
from utilities.forms.fields import DynamicModelChoiceField, QueryField
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from utilities.forms.fields import DynamicModelChoiceField
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin
|
||||
|
||||
__all__ = (
|
||||
@@ -16,7 +15,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form):
|
||||
class NetBoxModelFilterSetForm(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.
|
||||
@@ -28,7 +27,7 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
|
||||
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||
a selector widget
|
||||
"""
|
||||
q = QueryField(
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ registry = Registry({
|
||||
'data_backends': dict(),
|
||||
'denormalized_fields': collections.defaultdict(list),
|
||||
'event_types': dict(),
|
||||
'filtersets': dict(),
|
||||
'model_features': dict(),
|
||||
'models': collections.defaultdict(set),
|
||||
'plugins': dict(),
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js.map
vendored
8
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,179 +0,0 @@
|
||||
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,9 +1,8 @@
|
||||
import { initFormElements } from './elements';
|
||||
import { initFilterModifiers } from './filterModifiers';
|
||||
import { initSpeedSelector } from './speedSelector';
|
||||
|
||||
export function initForms(): void {
|
||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
|
||||
for (const func of [initFormElements, initSpeedSelector]) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,3 @@ 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,7 +6,6 @@ from netbox.filtersets import (
|
||||
NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
|
||||
)
|
||||
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||
from utilities.filtersets import register_filterset
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
@@ -25,7 +24,6 @@ __all__ = (
|
||||
# Contacts
|
||||
#
|
||||
|
||||
@register_filterset
|
||||
class ContactGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
@@ -61,7 +59,6 @@ class ContactGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -69,7 +66,6 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactFilterSet(PrimaryModelFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
@@ -104,7 +100,6 @@ class ContactFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -175,7 +170,6 @@ class ContactModelFilterSet(django_filters.FilterSet):
|
||||
# Tenancy
|
||||
#
|
||||
|
||||
@register_filterset
|
||||
class TenantGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
@@ -206,7 +200,6 @@ class TenantGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -7,7 +8,6 @@ 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,7 +19,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class GroupFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -65,7 +64,6 @@ class GroupFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class UserFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -122,7 +120,6 @@ class UserFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TokenFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -184,7 +181,6 @@ class TokenFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -252,7 +248,6 @@ class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
return queryset.exclude(actions__contains=[action])
|
||||
|
||||
|
||||
@register_filterset
|
||||
class OwnerGroupFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -272,7 +267,6 @@ class OwnerGroupFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class OwnerFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,20 +17,11 @@ __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,8 +4,7 @@ from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from utilities.forms.fields import QueryField
|
||||
from utilities.forms.mixins import BackgroundJobMixin, FilterModifierMixin
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
|
||||
__all__ = (
|
||||
'BulkDeleteForm',
|
||||
@@ -141,11 +140,11 @@ class CSVModelForm(forms.ModelForm):
|
||||
return super().clean()
|
||||
|
||||
|
||||
class FilterForm(FilterModifierMixin, forms.Form):
|
||||
class FilterForm(forms.Form):
|
||||
"""
|
||||
Base Form class for FilterSet forms.
|
||||
"""
|
||||
q = QueryField(
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
@@ -5,100 +5,13 @@ 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'),
|
||||
@@ -162,68 +75,3 @@ 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,5 +1,4 @@
|
||||
from .apiselect import *
|
||||
from .datetime import *
|
||||
from .misc import *
|
||||
from .modifiers import *
|
||||
from .select import *
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
<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,11 +5,9 @@ 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
|
||||
|
||||
@@ -420,20 +418,7 @@ def applied_filters(context, model, form, query_params):
|
||||
continue
|
||||
|
||||
querydict = query_params.copy()
|
||||
|
||||
# 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:
|
||||
if filter_name not in querydict:
|
||||
continue
|
||||
|
||||
# Skip saved filters, as they're displayed alongside the quick search widget
|
||||
@@ -441,46 +426,14 @@ def applied_filters(context, model, form, query_params):
|
||||
continue
|
||||
|
||||
bound_field = form.fields[filter_name].get_bound_field(form, 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
|
||||
querydict.pop(filter_name)
|
||||
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': param_name, # Use actual param name for removal link
|
||||
'value': form.cleaned_data.get(filter_name),
|
||||
'name': filter_name,
|
||||
'value': form.cleaned_data[filter_name],
|
||||
'link_url': f'?{querydict.urlencode()}',
|
||||
'link_text': link_text,
|
||||
'link_text': f'{bound_field.label}: {display_value}',
|
||||
})
|
||||
|
||||
save_link = None
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
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,7 +27,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ClusterTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -35,7 +34,6 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -43,7 +41,6 @@ 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(),
|
||||
@@ -84,7 +81,6 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet,
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualMachineFilterSet(
|
||||
PrimaryModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
@@ -245,7 +241,6 @@ class VirtualMachineFilterSet(
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine__cluster',
|
||||
@@ -308,7 +303,6 @@ class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxMod
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class VirtualDiskFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine',
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 *
|
||||
@@ -27,7 +26,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -35,7 +33,6 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=TunnelStatusChoices
|
||||
@@ -78,7 +75,6 @@ class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class TunnelTerminationFilterSet(NetBoxModelFilterSet):
|
||||
tunnel_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='tunnel',
|
||||
@@ -128,7 +124,6 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
|
||||
fields = ('id', 'termination_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IKEProposalFilterSet(PrimaryModelFilterSet):
|
||||
ike_policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ike_policies',
|
||||
@@ -168,7 +163,6 @@ class IKEProposalFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IKEPolicyFilterSet(PrimaryModelFilterSet):
|
||||
version = django_filters.MultipleChoiceFilter(
|
||||
choices=IKEVersionChoices
|
||||
@@ -200,7 +194,6 @@ class IKEPolicyFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecProposalFilterSet(PrimaryModelFilterSet):
|
||||
ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ipsec_policies',
|
||||
@@ -234,7 +227,6 @@ class IPSecProposalFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecPolicyFilterSet(PrimaryModelFilterSet):
|
||||
pfs_group = django_filters.MultipleChoiceFilter(
|
||||
choices=DHGroupChoices
|
||||
@@ -263,7 +255,6 @@ class IPSecPolicyFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class IPSecProfileFilterSet(PrimaryModelFilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
choices=IPSecModeChoices
|
||||
@@ -303,7 +294,6 @@ class IPSecProfileFilterSet(PrimaryModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=L2VPNTypeChoices,
|
||||
@@ -350,7 +340,6 @@ 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', 'tag', 'l2vpn_id'),
|
||||
FieldSet('filter_id', 'l2vpn_id'),
|
||||
FieldSet(
|
||||
'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
|
||||
name=_('Assigned Object')
|
||||
@@ -303,4 +303,3 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from dcim.choices import LinkStatusChoices
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
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 *
|
||||
|
||||
@@ -19,7 +18,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=WirelessLANGroup.objects.all()
|
||||
@@ -46,7 +44,6 @@ class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
@@ -90,7 +87,6 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user