mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-05 11:46:50 -06:00
Compare commits
4 Commits
353e627bf2
...
55fa832a2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fa832a2f | ||
|
|
7eefb07554 | ||
|
|
20c260b126 | ||
|
|
7bca9f5d6d |
@@ -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=(
|
||||
@@ -50,7 +55,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
|
||||
@@ -70,7 +75,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):
|
||||
@@ -98,7 +105,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()
|
||||
```
|
||||
@@ -114,7 +123,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()
|
||||
```
|
||||
|
||||
@@ -25,10 +25,12 @@ from extras.models import Bookmark
|
||||
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
|
||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||
from netbox.config import get_config
|
||||
from netbox.ui import layout
|
||||
from netbox.views import generic
|
||||
from users import forms
|
||||
from users.models import UserConfig
|
||||
from users.tables import TokenTable
|
||||
from users.ui.panels import TokenExamplePanel, TokenPanel
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.string import remove_linebreaks
|
||||
from utilities.views import register_model_view
|
||||
@@ -342,12 +344,21 @@ class UserTokenListView(LoginRequiredMixin, View):
|
||||
|
||||
@register_model_view(UserToken)
|
||||
class UserTokenView(LoginRequiredMixin, View):
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
TokenPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TokenExamplePanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get(self, request, pk):
|
||||
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
||||
|
||||
return render(request, 'account/token.html', {
|
||||
'object': token,
|
||||
'layout': self.layout,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
.cache
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"arrowFunctions": true
|
||||
}
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "prettier"],
|
||||
"settings": {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-unused-vars": "off",
|
||||
"no-inner-declarations": "off",
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{
|
||||
"allowSingleExtends": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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
86
netbox/project-static/eslint.config.js
Normal file
86
netbox/project-static/eslint.config.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['**/dist', '**/node_modules', '**/.cache']),
|
||||
{
|
||||
extends: fixupConfigRules(
|
||||
compat.extends(
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'prettier',
|
||||
),
|
||||
),
|
||||
|
||||
plugins: {
|
||||
'@typescript-eslint': fixupPluginRules(typescriptEslint),
|
||||
prettier: fixupPluginRules(prettier),
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
arrowFunctions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'no-inner-declarations': 'off',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'global-require': 'off',
|
||||
'import/no-dynamic-require': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "netbox",
|
||||
"version": "4.4.0",
|
||||
"type": "module",
|
||||
"version": "4.5.0",
|
||||
"main": "dist/netbox.js",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
@@ -8,14 +9,14 @@
|
||||
"netbox-graphiql"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "node bundle.js",
|
||||
"bundle:styles": "node bundle.js --styles",
|
||||
"bundle:scripts": "node bundle.js --scripts",
|
||||
"bundle": "node bundle.cjs",
|
||||
"bundle:styles": "node bundle.cjs --styles",
|
||||
"bundle:scripts": "node bundle.cjs --scripts",
|
||||
"format": "yarn format:scripts && yarn format:styles",
|
||||
"format:scripts": "prettier -w src/**/*.ts",
|
||||
"format:styles": "prettier -w styles/**/*.scss",
|
||||
"validate": "yarn validate:types && yarn validate:lint",
|
||||
"validate:lint": "eslint -c .eslintrc ./src/**/*.ts",
|
||||
"validate:lint": "eslint ./src/**/*.ts",
|
||||
"validate:types": "tsc --noEmit",
|
||||
"validate:formatting": "yarn validate:formatting:scripts && yarn validate:formatting:styles",
|
||||
"validate:formatting:styles": "prettier -c styles/**/*.scss",
|
||||
@@ -36,20 +37,24 @@
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/node": "^22.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "<9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "<5.5"
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.7.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
9
netbox/templates/users/panels/token_example.html
Normal file
9
netbox/templates/users/panels/token_example.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends 'ui/panels/_base.html' %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div id="token-example" class="card-body font-monospace">curl -X GET \<br />
|
||||
-H "Authorization: {{ object.get_auth_header_prefix }}<mark><TOKEN></mark>" \<br />
|
||||
-H "Content-Type: application/json" \<br />
|
||||
-H "Accept: application/json; indent=4" \<br />
|
||||
{{ request.scheme }}://{{ request.get_host }}{% url "api-status" %}</div>
|
||||
{% endblock panel_content %}
|
||||
@@ -1,73 +1,4 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Token" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Version" %}</th>
|
||||
<td>{{ object.version }}</td>
|
||||
</tr>
|
||||
{% if object.version == 1 %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Token" %}</th>
|
||||
<td>{{ object.partial }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Key" %}</th>
|
||||
<td>{{ object }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Pepper ID" %}</th>
|
||||
<td>{{ object.pepper_id }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "User" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'users:user' pk=object.user.pk %}">{{ object.user }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Write enabled" %}</th>
|
||||
<td>{% checkmark object.write_enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Created" %}</th>
|
||||
<td>{{ object.created|isodatetime }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Expires" %}</th>
|
||||
<td>{{ object.expires|isodatetime|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last used" %}</th>
|
||||
<td>{{ object.last_used|isodatetime|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Allowed IPs" %}</th>
|
||||
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -201,6 +201,15 @@ class Token(models.Model):
|
||||
"""
|
||||
return self.enabled and not self.is_expired
|
||||
|
||||
def get_auth_header_prefix(self):
|
||||
"""
|
||||
Return the HTTP Authorization header prefix for this token.
|
||||
"""
|
||||
if self.v1:
|
||||
return 'Token '
|
||||
if self.v2:
|
||||
return f'Bearer {TOKEN_PREFIX}{self.key}.'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
0
netbox/users/ui/__init__.py
Normal file
0
netbox/users/ui/__init__.py
Normal file
25
netbox/users/ui/panels.py
Normal file
25
netbox/users/ui/panels.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import actions, attrs, panels
|
||||
|
||||
|
||||
class TokenPanel(panels.ObjectAttributesPanel):
|
||||
version = attrs.NumericAttr('version')
|
||||
key = attrs.TextAttr('key')
|
||||
token = attrs.TextAttr('partial')
|
||||
pepper_id = attrs.NumericAttr('pepper_id')
|
||||
user = attrs.RelatedObjectAttr('user', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
write_enabled = attrs.BooleanAttr('write_enabled')
|
||||
expires = attrs.TextAttr('expires')
|
||||
last_used = attrs.TextAttr('last_used')
|
||||
allowed_ips = attrs.TextAttr('allowed_ips')
|
||||
|
||||
|
||||
class TokenExamplePanel(panels.Panel):
|
||||
template_name = 'users/panels/token_example.html'
|
||||
title = _('Example Usage')
|
||||
actions = [
|
||||
actions.CopyContent('token-example')
|
||||
]
|
||||
@@ -3,7 +3,9 @@ from django.db.models import Count
|
||||
from core.models import ObjectChange
|
||||
from core.tables import ObjectChangeTable
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
||||
from netbox.ui import layout
|
||||
from netbox.views import generic
|
||||
from users.ui import panels
|
||||
from utilities.query import count_related
|
||||
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
@@ -26,6 +28,14 @@ class TokenListView(generic.ObjectListView):
|
||||
@register_model_view(Token)
|
||||
class TokenView(generic.ObjectView):
|
||||
queryset = Token.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.TokenPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.TokenExamplePanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Token, 'add', detail=False)
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user