Merge branch 'feature' into 20564-port-mappings

This commit is contained in:
Jeremy Stretch
2025-12-09 11:12:35 -05:00
87 changed files with 4462 additions and 99784 deletions

View File

@@ -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`.

View File

@@ -6,12 +6,17 @@ Filter sets define the mechanisms available for filtering or searching through a
To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
!!! info "New in NetBox v4.5: FilterSet Registration"
NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
```python
# filtersets.py
import django_filters
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
from .models import MyModel
@register_filterset
class MyFilterSet(NetBoxModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=(
@@ -42,7 +47,7 @@ class MyModelListView(ObjectListView):
filterset = MyModelFilterSet
```
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
```python
# api/views.py
@@ -62,7 +67,9 @@ The `ObjectListView` has a field called Quick Search. For Quick Search to work t
```python
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
@register_filterset
class MyFilterSet(NetBoxModelFilterSet):
...
def search(self, queryset, name, value):
@@ -90,7 +97,9 @@ This class filters `tags` using the `slug` field. For example:
```python
from django_filters import FilterSet
from extras.filters import TagFilter
from utilities.filtersets import register_filterset
@register_filterset
class MyModelFilterSet(FilterSet):
tag = TagFilter()
```
@@ -106,7 +115,9 @@ This class filters `tags` using the `id` field. For example:
```python
from django_filters import FilterSet
from extras.filters import TagIDFilter
from utilities.filtersets import register_filterset
@register_filterset
class MyModelFilterSet(FilterSet):
tag_id = TagIDFilter()
```

View File

@@ -325,14 +325,14 @@ class CircuitTypeType(OrganizationalObjectType):
### Change filters.py
Strawberry currently doesn't directly support django-filter, so an explicit filters.py file will need to be created. NetBox includes a new `autotype_decorator` used to automatically wrap FilterSets to reduce the required code to a minimum.
Filter classes should inherit from `netbox.graphql.filters.BaseModelFilter`.
```python title="New"
import strawberry
import strawberry_django
from circuits import filtersets, models
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
from netbox.graphql.filters import BaseModelFilter
__all__ = (
'CircuitFilter',
@@ -340,8 +340,7 @@ __all__ = (
@strawberry_django.filter(models.Circuit, lookups=True)
@autotype_decorator(filtersets.CircuitFilterSet)
class CircuitFilter(BaseFilterMixin):
class CircuitFilter(BaseModelFilter):
pass
```

View File

@@ -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,
})

View File

@@ -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',

View File

@@ -3,17 +3,18 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin
from strawberry_django import BaseFilterLookup
if TYPE_CHECKING:
from netbox.graphql.enums import ColorEnum
__all__ = (
'BaseCircuitTypeFilterMixin',
'CircuitTypeFilterMixin',
)
@dataclass
class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
class CircuitTypeFilterMixin:
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)

View File

@@ -7,17 +7,12 @@ from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
from circuits import models
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import (
DistanceFilterMixin,
ImageAttachmentFilterMixin,
OrganizationalModelFilterMixin,
PrimaryModelFilterMixin,
)
from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from .filter_mixins import BaseCircuitTypeFilterMixin
if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter
@@ -43,10 +38,9 @@ __all__ = (
@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
class CircuitTerminationFilter(
BaseObjectTypeFilterMixin,
CustomFieldsFilterMixin,
TagsFilterMixin,
ChangeLogFilterMixin,
ChangeLoggedModelFilter,
CabledObjectModelFilterMixin,
):
circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -95,7 +89,7 @@ class CircuitFilter(
ImageAttachmentFilterMixin,
DistanceFilterMixin,
TenancyFilterMixin,
PrimaryModelFilterMixin
PrimaryModelFilter
):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -124,19 +118,17 @@ class CircuitFilter(
@strawberry_django.filter_type(models.CircuitType, lookups=True)
class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
class CircuitGroupAssignmentFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -151,7 +143,7 @@ class CircuitGroupAssignmentFilter(
@strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -161,7 +153,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -171,7 +163,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilterMixin):
class ProviderNetworkFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -181,12 +173,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -209,9 +201,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
class VirtualCircuitTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -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',

View File

@@ -4,31 +4,18 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry import ID
from strawberry_django import FilterLookup, DatetimeFilterLookup
from strawberry_django import DatetimeFilterLookup
if TYPE_CHECKING:
from .filters import *
__all__ = (
'BaseFilterMixin',
'BaseObjectTypeFilterMixin',
'ChangeLogFilterMixin',
'ChangeLoggingMixin',
)
# @strawberry.input
class BaseFilterMixin: ...
@dataclass
class BaseObjectTypeFilterMixin(BaseFilterMixin):
id: FilterLookup[ID] | None = strawberry_django.filter_field()
@dataclass
class ChangeLogFilterMixin(BaseFilterMixin):
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ChangeLoggingMixin:
# TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange
changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()

View File

@@ -8,8 +8,7 @@ from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
from core import models
from core.graphql.filter_mixins import BaseFilterMixin
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
from .enums import *
if TYPE_CHECKING:
@@ -25,8 +24,7 @@ __all__ = (
@strawberry_django.filter_type(models.DataFile, lookups=True)
class DataFileFilter(BaseFilterMixin):
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class DataFileFilter(BaseModelFilter):
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -41,7 +39,7 @@ class DataFileFilter(BaseFilterMixin):
@strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilterMixin):
class DataSourceFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field()
source_url: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -60,8 +58,7 @@ class DataSourceFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.ObjectChange, lookups=True)
class ObjectChangeFilter(BaseFilterMixin):
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ObjectChangeFilter(BaseModelFilter):
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -88,7 +85,6 @@ class ObjectChangeFilter(BaseFilterMixin):
@strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseFilterMixin):
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ContentTypeFilter(BaseModelFilter):
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -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,
@@ -896,6 +915,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
@register_filterset
class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -913,6 +933,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
@register_filterset
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
@@ -920,6 +941,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
fields = ('id', 'name', 'label', 'position', 'description')
@register_filterset
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class Meta:
@@ -927,6 +949,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(),
@@ -970,6 +993,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(),
@@ -1004,6 +1028,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(),
@@ -1061,6 +1086,7 @@ class PlatformFilterSet(NestedGroupModelFilterSet):
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
@register_filterset
class DeviceFilterSet(
PrimaryModelFilterSet,
TenancyFilterSet,
@@ -1363,6 +1389,7 @@ class DeviceFilterSet(
return queryset.exclude(params)
@register_filterset
class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
field_name='device',
@@ -1412,6 +1439,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',
@@ -1700,6 +1728,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,
@@ -1711,6 +1740,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,
@@ -1722,6 +1752,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,
@@ -1736,6 +1767,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
)
@register_filterset
class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypeChoices,
@@ -1762,6 +1794,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
)
@register_filterset
class MACAddressFilterSet(PrimaryModelFilterSet):
mac_address = MultiValueMACAddressFilter()
assigned_object_type = ContentTypeFilter()
@@ -1943,6 +1976,7 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
)
@register_filterset
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
CabledObjectFilterSet,
@@ -2105,6 +2139,7 @@ class InterfaceFilterSet(
)
@register_filterset
class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -2125,6 +2160,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
)
@register_filterset
class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -2145,6 +2181,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
)
@register_filterset
class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=ModuleBay.objects.all(),
@@ -2161,6 +2198,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(),
@@ -2178,6 +2216,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
fields = ('id', 'name', 'label', 'description')
@register_filterset
class InventoryItemFilterSet(DeviceComponentFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(),
@@ -2230,6 +2269,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
return queryset.filter(qs_filter)
@register_filterset
class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
@@ -2237,6 +2277,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'color', 'description')
@register_filterset
class VirtualChassisFilterSet(PrimaryModelFilterSet):
master_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
@@ -2313,6 +2354,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'
@@ -2485,6 +2527,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
return self.filter_by_termination_object(queryset, CircuitTermination, value)
@register_filterset
class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
termination_type = ContentTypeFilter()
@@ -2493,6 +2536,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(),
@@ -2551,6 +2595,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(),

View File

@@ -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):

View File

@@ -6,9 +6,7 @@ import strawberry_django
from strawberry import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin
from core.graphql.filters import ContentTypeFilter
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin
from .enums import *
if TYPE_CHECKING:
@@ -22,16 +20,16 @@ __all__ = (
'ComponentModelFilterMixin',
'ComponentTemplateFilterMixin',
'InterfaceBaseFilterMixin',
'ModularComponentModelFilterMixin',
'ModularComponentFilterMixin',
'ModularComponentTemplateFilterMixin',
'RackBaseFilterMixin',
'RackFilterMixin',
'RenderConfigFilterMixin',
'ScopedFilterMixin',
)
@dataclass
class ScopedFilterMixin(BaseFilterMixin):
class ScopedFilterMixin:
scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -39,7 +37,7 @@ class ScopedFilterMixin(BaseFilterMixin):
@dataclass
class ComponentModelFilterMixin(NetBoxModelFilterMixin):
class ComponentModelFilterMixin:
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -48,7 +46,7 @@ class ComponentModelFilterMixin(NetBoxModelFilterMixin):
@dataclass
class ModularComponentModelFilterMixin(ComponentModelFilterMixin):
class ModularComponentFilterMixin(ComponentModelFilterMixin):
module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
module_id: ID | None = strawberry_django.filter_field()
inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -57,7 +55,7 @@ class ModularComponentModelFilterMixin(ComponentModelFilterMixin):
@dataclass
class CabledObjectModelFilterMixin(BaseFilterMixin):
class CabledObjectModelFilterMixin:
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
cable_id: ID | None = strawberry_django.filter_field()
cable_end: (
@@ -67,7 +65,7 @@ class CabledObjectModelFilterMixin(BaseFilterMixin):
@dataclass
class ComponentTemplateFilterMixin(ChangeLogFilterMixin):
class ComponentTemplateFilterMixin:
device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -85,7 +83,7 @@ class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin):
@dataclass
class RenderConfigFilterMixin(BaseFilterMixin):
class RenderConfigFilterMixin:
config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -93,7 +91,7 @@ class RenderConfigFilterMixin(BaseFilterMixin):
@dataclass
class InterfaceBaseFilterMixin(BaseFilterMixin):
class InterfaceBaseFilterMixin:
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@@ -124,7 +122,7 @@ class InterfaceBaseFilterMixin(BaseFilterMixin):
@dataclass
class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin):
class RackFilterMixin:
width: BaseFilterLookup[Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)

View File

@@ -6,29 +6,25 @@ import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from dcim import models
from dcim.constants import *
from dcim.graphql.enums import InterfaceKindEnum
from dcim.graphql.filter_mixins import (
ComponentModelFilterMixin, ComponentTemplateFilterMixin, ModularComponentFilterMixin,
ModularComponentTemplateFilterMixin, RackFilterMixin,
)
from extras.graphql.filter_mixins import ConfigContextFilterMixin
from netbox.graphql.filter_mixins import (
PrimaryModelFilterMixin,
OrganizationalModelFilterMixin,
NestedGroupModelFilterMixin,
ImageAttachmentFilterMixin,
WeightFilterMixin,
from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
from netbox.graphql.filters import (
BaseModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
NetBoxModelFilter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
from .filter_mixins import (
CabledObjectModelFilterMixin,
ComponentModelFilterMixin,
ComponentTemplateFilterMixin,
InterfaceBaseFilterMixin,
ModularComponentModelFilterMixin,
ModularComponentTemplateFilterMixin,
RackBaseFilterMixin,
RenderConfigFilterMixin,
)
@@ -98,7 +94,7 @@ __all__ = (
@strawberry_django.filter_type(models.Cable, lookups=True)
class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -121,7 +117,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
@strawberry_django.filter_type(models.CableTermination, lookups=True)
class CableTerminationFilter(ChangeLogFilterMixin):
class CableTerminationFilter(ChangeLoggedModelFilter):
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
cable_id: ID | None = strawberry_django.filter_field()
cable_end: BaseFilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -134,7 +130,7 @@ class CableTerminationFilter(ChangeLogFilterMixin):
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -144,14 +140,14 @@ class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -161,7 +157,7 @@ class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectMode
@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -174,7 +170,7 @@ class DeviceFilter(
ImageAttachmentFilterMixin,
RenderConfigFilterMixin,
ConfigContextFilterMixin,
PrimaryModelFilterMixin,
PrimaryModelFilter,
):
device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -287,7 +283,7 @@ class DeviceFilter(
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin):
class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -295,12 +291,12 @@ class DeviceBayFilter(ComponentModelFilterMixin):
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
pass
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -320,7 +316,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -328,7 +324,7 @@ class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
@strawberry_django.filter_type(models.DeviceType, lookups=True)
class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -404,7 +400,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
@strawberry_django.filter_type(models.FrontPort, lookups=True)
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -414,7 +410,7 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -424,7 +420,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
@strawberry_django.filter_type(models.PortMapping, lookups=True)
class PortMappingFilter(BaseObjectTypeFilterMixin):
class PortMappingFilter(BaseModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -437,7 +433,7 @@ class PortMappingFilter(BaseObjectTypeFilterMixin):
@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True)
class PortTemplateMappingFilter(BaseObjectTypeFilterMixin):
class PortTemplateMappingFilter(BaseModelFilter):
device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -455,7 +451,7 @@ class PortTemplateMappingFilter(BaseObjectTypeFilterMixin):
@strawberry_django.filter_type(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilterMixin):
class MACAddressFilter(PrimaryModelFilter):
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -482,7 +478,12 @@ class MACAddressFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.Interface, lookups=True)
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
class InterfaceFilter(
ModularComponentFilterMixin,
InterfaceBaseFilterMixin,
CabledObjectModelFilterMixin,
NetBoxModelFilter
):
vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -572,7 +573,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin
@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -594,7 +595,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
@strawberry_django.filter_type(models.InventoryItem, lookups=True)
class InventoryItemFilter(ComponentModelFilterMixin):
class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -621,14 +622,14 @@ class InventoryItemFilter(ComponentModelFilterMixin):
@strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
class InventoryItemRoleFilter(OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.Location, lookups=True)
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -644,12 +645,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
@strawberry_django.filter_type(models.Manufacturer, lookups=True)
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.Module, lookups=True)
class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -698,7 +699,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
@strawberry_django.filter_type(models.ModuleBay, lookups=True)
class ModuleBayFilter(ModularComponentModelFilterMixin):
class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -707,17 +708,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin):
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
class ModuleTypeProfileFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleType, lookups=True)
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -768,7 +769,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
@strawberry_django.filter_type(models.Platform, lookups=True)
class PlatformFilter(OrganizationalModelFilterMixin):
class PlatformFilter(OrganizationalModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -780,7 +781,7 @@ class PlatformFilter(OrganizationalModelFilterMixin):
@strawberry_django.filter_type(models.PowerFeed, lookups=True)
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -815,7 +816,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
@strawberry_django.filter_type(models.PowerOutlet, lookups=True)
class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -835,7 +836,7 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -849,7 +850,7 @@ class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
@strawberry_django.filter_type(models.PowerPanel, lookups=True)
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -862,7 +863,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
@strawberry_django.filter_type(models.PowerPort, lookups=True)
class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -875,7 +876,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -888,7 +889,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
@strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(RackBaseFilterMixin):
class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -903,7 +904,14 @@ class RackTypeFilter(RackBaseFilterMixin):
@strawberry_django.filter_type(models.Rack, lookups=True)
class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
class RackFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
TenancyFilterMixin,
WeightFilterMixin,
RackFilterMixin,
PrimaryModelFilter
):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -937,7 +945,7 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
@strawberry_django.filter_type(models.RackReservation, lookups=True)
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field()
units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -952,14 +960,14 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.RackRole, lookups=True)
class RackRoleFilter(OrganizationalModelFilterMixin):
class RackRoleFilter(OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.RearPort, lookups=True)
class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -972,7 +980,7 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi
@strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -985,7 +993,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
@strawberry_django.filter_type(models.Region, lookups=True)
class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -995,7 +1003,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
@strawberry_django.filter_type(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -1031,7 +1039,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
@strawberry_django.filter_type(models.SiteGroup, lookups=True)
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -1041,7 +1049,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
@strawberry_django.filter_type(models.VirtualChassis, lookups=True)
class VirtualChassisFilter(PrimaryModelFilterMixin):
class VirtualChassisFilter(PrimaryModelFilter):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -1053,7 +1061,7 @@ class VirtualChassisFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -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',

View File

@@ -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')),

View File

@@ -3,9 +3,6 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry_django import FilterLookup
from core.graphql.filter_mixins import BaseFilterMixin
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import JSONFilter
@@ -16,37 +13,30 @@ __all__ = (
'JournalEntriesFilterMixin',
'TagsFilterMixin',
'ConfigContextFilterMixin',
'TagBaseFilterMixin',
)
@dataclass
class CustomFieldsFilterMixin(BaseFilterMixin):
class CustomFieldsFilterMixin:
custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@dataclass
class JournalEntriesFilterMixin(BaseFilterMixin):
class JournalEntriesFilterMixin:
journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@dataclass
class TagsFilterMixin(BaseFilterMixin):
class TagsFilterMixin:
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
@dataclass
class ConfigContextFilterMixin(BaseFilterMixin):
class ConfigContextFilterMixin:
local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@dataclass
class TagBaseFilterMixin(BaseFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -5,10 +5,10 @@ import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from extras import models
from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import SyncedDataFilterMixin
from netbox.graphql.filters import ChangeLoggedModelFilter, PrimaryModelFilter
if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter
@@ -42,7 +42,7 @@ __all__ = (
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@@ -99,14 +99,14 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] = strawberry_django.filter_field()
description: FilterLookup[str] = strawberry_django.filter_field()
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -120,7 +120,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
@strawberry_django.filter_type(models.CustomField, lookups=True)
class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class CustomFieldFilter(ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -179,7 +179,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
base_choices: (
@@ -194,7 +194,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin
@strawberry_django.filter_type(models.CustomLink, lookups=True)
class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class CustomLinkFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
link_text: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -212,7 +212,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -226,7 +226,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
@strawberry_django.filter_type(models.ImageAttachment, lookups=True)
class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class ImageAttachmentFilter(ChangeLoggedModelFilter):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -241,7 +241,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -257,7 +257,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class NotificationGroupFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -265,7 +265,7 @@ class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class SavedFilterFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -282,7 +282,7 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.TableConfig, lookups=True)
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class TableConfigFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -295,7 +295,9 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
@strawberry_django.filter_type(models.Tag, lookups=True)
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
class TagFilter(ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -303,7 +305,7 @@ class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMi
@strawberry_django.filter_type(models.Webhook, lookups=True)
class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -324,7 +326,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt
@strawberry_django.filter_type(models.EventRule, lookups=True)
class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (

View File

@@ -46,6 +46,10 @@ class ConfigContextQuerySet(RestrictedQuerySet):
# Match against the directly assigned role as well as any parent roles.
device_roles = obj.role.get_ancestors(include_self=True) if obj.role else []
# Match against the directly assigned platform as well as any parent platforms.
platform = getattr(obj, 'platform', None)
platforms = platform.get_ancestors(include_self=True) if platform else []
queryset = self.filter(
Q(regions__in=regions) | Q(regions=None),
Q(site_groups__in=sitegroups) | Q(site_groups=None),
@@ -53,7 +57,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
Q(locations__in=locations) | Q(locations=None),
Q(device_types=device_type) | Q(device_types=None),
Q(roles__in=device_roles) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
Q(platforms__in=platforms) | Q(platforms=None),
Q(cluster_types=cluster_type) | Q(cluster_types=None),
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
Q(clusters=cluster) | Q(clusters=None),
@@ -103,7 +107,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
"content_type__model": self.model._meta.model_name
}
base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(cluster_types=OuterRef('cluster__type')) | Q(cluster_types=None),
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
@@ -167,6 +170,15 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) | Q(roles=None)),
Q.AND
)
base_query.add(
(Q(
platforms__tree_id=OuterRef('platform__tree_id'),
platforms__level__lte=OuterRef('platform__level'),
platforms__lft__lte=OuterRef('platform__lft'),
platforms__rght__gte=OuterRef('platform__rght'),
) | Q(platforms=None)),
Q.AND
)
return base_query

View File

@@ -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(

View File

@@ -3,21 +3,20 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from core.graphql.filter_mixins import BaseFilterMixin
from strawberry_django import BaseFilterLookup
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerLookup
from .enums import *
__all__ = (
'ServiceBaseFilterMixin',
'ServiceFilterMixin',
)
@dataclass
class ServiceBaseFilterMixin(BaseFilterMixin):
protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
class ServiceFilterMixin:
protocol: BaseFilterLookup[Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (

View File

@@ -9,12 +9,13 @@ from netaddr.core import AddrFormatError
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from dcim.graphql.filter_mixins import ScopedFilterMixin
from dcim.models import Device
from ipam import models
from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
from ipam.graphql.filter_mixins import ServiceFilterMixin
from netbox.graphql.filters import (
ChangeLoggedModelFilter, NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
@@ -49,7 +50,7 @@ __all__ = (
@strawberry_django.filter_type(models.ASN, lookups=True)
class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -64,7 +65,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.ASNRange, lookups=True)
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -78,7 +79,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
@strawberry_django.filter_type(models.Aggregate, lookups=True)
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
@@ -111,7 +112,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
class FHRPGroupFilter(PrimaryModelFilterMixin):
class FHRPGroupFilter(PrimaryModelFilter):
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -129,7 +130,7 @@ class FHRPGroupFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -168,7 +169,7 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
@strawberry_django.filter_type(models.IPAddress, lookups=True)
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
address: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
@@ -219,7 +220,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_type(models.IPRange, lookups=True)
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -273,7 +274,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
@strawberry_django.filter_type(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
@@ -310,19 +311,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
@strawberry_django.filter_type(models.RIR, lookups=True)
class RIRFilter(OrganizationalModelFilterMixin):
class RIRFilter(OrganizationalModelFilter):
is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Role, lookups=True)
class RoleFilter(OrganizationalModelFilterMixin):
class RoleFilter(OrganizationalModelFilter):
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -339,7 +340,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.Service, lookups=True)
class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -351,12 +352,12 @@ class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilt
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLAN, lookups=True)
class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@@ -388,19 +389,19 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
class VLANTranslationPolicyFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
class VLANTranslationRuleFilter(NetBoxModelFilter):
policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -415,7 +416,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
@strawberry_django.filter_type(models.VRF, lookups=True)
class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
rd: FilterLookup[str] | None = strawberry_django.filter_field()
enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()

View File

@@ -38,7 +38,7 @@ class TokenAuthentication(BaseAuthentication):
try:
auth_value = auth[1].decode()
except UnicodeError:
raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters")
raise exceptions.AuthenticationFailed('Invalid authorization header: Token contains invalid characters')
# Infer token version from presence or absence of prefix
version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1
@@ -75,17 +75,21 @@ class TokenAuthentication(BaseAuthentication):
client_ip = get_client_ip(request)
if client_ip is None:
raise exceptions.AuthenticationFailed(
"Client IP address could not be determined for validation. Check that the HTTP server is "
"correctly configured to pass the required header(s)."
'Client IP address could not be determined for validation. Check that the HTTP server is '
'correctly configured to pass the required header(s).'
)
if not token.validate_client_ip(client_ip):
raise exceptions.AuthenticationFailed(
f"Source IP {client_ip} is not permitted to authenticate using this token."
)
# Enforce the Token is enabled
if not token.enabled:
raise exceptions.AuthenticationFailed('Token disabled')
# Enforce the Token's expiration time, if one has been set.
if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
raise exceptions.AuthenticationFailed('Token expired')
# Update last used, but only once per minute at most. This reduces write load on the database
if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from django_rq.queues import get_connection
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
@@ -12,6 +13,7 @@ from rq.worker import Worker
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.plugins.utils import get_installed_plugins
from users.api.serializers import UserSerializer
from utilities.apps import get_installed_apps
@@ -62,3 +64,15 @@ class StatusView(APIView):
'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')),
})
class AuthenticationCheckView(APIView):
"""
Return the user making the request, if authenticated successfully.
"""
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: OpenApiTypes.OBJECT})
def get(self, request):
serializer = UserSerializer(request.user, context={'request': request})
return Response(serializer.data)

View File

@@ -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

View File

@@ -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')
)

View File

@@ -4,19 +4,11 @@ from typing import TypeVar, TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import ID
from strawberry_django import BaseFilterLookup, FilterLookup, DatetimeFilterLookup
from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
__all__ = (
'DistanceFilterMixin',
'ImageAttachmentFilterMixin',
'NestedGroupModelFilterMixin',
'NetBoxModelFilterMixin',
'OrganizationalModelFilterMixin',
'PrimaryModelFilterMixin',
'SyncedDataFilterMixin',
'WeightFilterMixin',
)
@@ -30,51 +22,15 @@ if TYPE_CHECKING:
from extras.graphql.filters import *
class NetBoxModelFilterMixin(
ChangeLogFilterMixin,
CustomFieldsFilterMixin,
JournalEntriesFilterMixin,
TagsFilterMixin,
BaseObjectTypeFilterMixin,
):
pass
@dataclass
class NestedGroupModelFilterMixin(NetBoxModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
parent_id: ID | None = strawberry_django.filter_field()
@dataclass
class OrganizationalModelFilterMixin(
ChangeLogFilterMixin,
CustomFieldsFilterMixin,
TagsFilterMixin,
BaseObjectTypeFilterMixin,
):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
class PrimaryModelFilterMixin(NetBoxModelFilterMixin):
description: FilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
class ImageAttachmentFilterMixin(BaseFilterMixin):
class ImageAttachmentFilterMixin:
images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@dataclass
class WeightFilterMixin(BaseFilterMixin):
class WeightFilterMixin:
weight: FilterLookup[float] | None = strawberry_django.filter_field()
weight_unit: BaseFilterLookup[Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
@@ -82,7 +38,7 @@ class WeightFilterMixin(BaseFilterMixin):
@dataclass
class SyncedDataFilterMixin(BaseFilterMixin):
class SyncedDataFilterMixin:
data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -97,7 +53,7 @@ class SyncedDataFilterMixin(BaseFilterMixin):
@dataclass
class DistanceFilterMixin(BaseFilterMixin):
class DistanceFilterMixin:
distance: FilterLookup[float] | None = strawberry_django.filter_field()
distance_unit: BaseFilterLookup[Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()

View File

@@ -0,0 +1,61 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
import strawberry_django
from strawberry import ID
from strawberry_django import FilterLookup
from core.graphql.filter_mixins import ChangeLoggingMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
if TYPE_CHECKING:
from .filters import *
__all__ = (
'BaseModelFilter',
'ChangeLoggedModelFilter',
'NestedGroupModelFilter',
'NetBoxModelFilter',
'OrganizationalModelFilter',
'PrimaryModelFilter',
)
@dataclass
class BaseModelFilter:
id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):
pass
class NetBoxModelFilter(
CustomFieldsFilterMixin,
JournalEntriesFilterMixin,
TagsFilterMixin,
ChangeLoggingMixin,
BaseModelFilter
):
pass
@dataclass
class NestedGroupModelFilter(NetBoxModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
parent_id: ID | None = strawberry_django.filter_field()
@dataclass
class OrganizationalModelFilter(NetBoxModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
class PrimaryModelFilter(NetBoxModelFilter):
description: FilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -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(),

View File

@@ -32,6 +32,18 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
def test_authentication_check(self):
url = reverse('api-authentication-check')
# Test an unauthenticated request
response = self.client.get(f'{url}')
self.assertEqual(response.status_code, 403)
# Test an authenticated request
response = self.client.get(f'{url}', **self.header)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], self.user.pk)
class OptionalLimitOffsetPaginationTest(TestCase):

View File

@@ -66,6 +66,32 @@ class TokenAuthenticationTestCase(APITestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data['detail'], "Invalid v2 token")
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_enabled(self):
url = reverse('dcim-api:site-list')
# Create v1 & v2 tokens
token1 = Token.objects.create(version=1, user=self.user, enabled=True)
token2 = Token.objects.create(version=2, user=self.user, enabled=True)
# Request with an enabled token should succeed
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}')
self.assertEqual(response.status_code, 200)
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}')
self.assertEqual(response.status_code, 200)
# Request with a disabled token should fail
token1.enabled = False
token1.save()
token2.enabled = False
token2.save()
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}')
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data['detail'], 'Token disabled')
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}')
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data['detail'], 'Token disabled')
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
def test_token_expiration(self):
url = reverse('dcim-api:site-list')

View File

@@ -5,7 +5,7 @@ from django.views.decorators.cache import cache_page
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView
from netbox.api.views import APIRootView, StatusView
from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
from netbox.graphql.schema import schema
from netbox.graphql.views import NetBoxGraphQLView
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
@@ -53,6 +53,7 @@ _patterns = [
path('api/vpn/', include('vpn.api.urls')),
path('api/wireless/', include('wireless.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'),
path('api/authentication-check/', AuthenticationCheckView.as_view(), name='api-authentication-check'),
# REST API schema
path(

View File

@@ -1,3 +0,0 @@
dist
node_modules
.cache

View File

@@ -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
}
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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,
},
],
},
},
]);

View File

@@ -1,14 +1,14 @@
{
"name": "netbox-graphiql",
"version": "4.3.0",
"version": "4.5.0",
"description": "NetBox GraphiQL Custom Front End",
"main": "dist/graphiql.js",
"license": "Apache-2.0",
"private": true,
"dependencies": {
"@graphiql/plugin-explorer": "3.2.5",
"graphiql": "3.8.3",
"graphql": "16.10.0",
"@graphiql/plugin-explorer": "3.2.6",
"graphiql": "4.1.2",
"graphql": "16.12.0",
"js-cookie": "3.0.5",
"react": "18.3.1",
"react-dom": "18.3.1"

View File

@@ -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"

View 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;
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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

View 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>&lt;TOKEN&gt;</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 %}

View File

@@ -1,69 +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 "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 %}

View File

@@ -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(),

View File

@@ -5,8 +5,6 @@ import strawberry
import strawberry_django
from strawberry import ID
from core.graphql.filter_mixins import BaseFilterMixin
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import TreeNodeFilter
from .filters import ContactAssignmentFilter, TenantFilter, TenantGroupFilter
@@ -18,14 +16,14 @@ __all__ = (
@dataclass
class ContactFilterMixin(BaseFilterMixin):
class ContactFilterMixin:
contacts: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@dataclass
class TenancyFilterMixin(BaseFilterMixin):
class TenancyFilterMixin:
tenant: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -5,12 +5,9 @@ import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from core.graphql.filter_mixins import ChangeLogFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import (
NestedGroupModelFilterMixin,
OrganizationalModelFilterMixin,
PrimaryModelFilterMixin,
from netbox.graphql.filters import (
ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
)
from tenancy import models
from .filter_mixins import ContactFilterMixin
@@ -57,7 +54,7 @@ __all__ = (
@strawberry_django.filter_type(models.Tenant, lookups=True)
class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
@@ -136,7 +133,7 @@ class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
@strawberry_django.filter_type(models.TenantGroup, lookups=True)
class TenantGroupFilter(OrganizationalModelFilterMixin):
class TenantGroupFilter(OrganizationalModelFilter):
parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -150,7 +147,7 @@ class TenantGroupFilter(OrganizationalModelFilterMixin):
@strawberry_django.filter_type(models.Contact, lookups=True)
class ContactFilter(PrimaryModelFilterMixin):
class ContactFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
title: FilterLookup[str] | None = strawberry_django.filter_field()
phone: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -166,19 +163,19 @@ class ContactFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.ContactRole, lookups=True)
class ContactRoleFilter(OrganizationalModelFilterMixin):
class ContactRoleFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.ContactGroup, lookups=True)
class ContactGroupFilter(NestedGroupModelFilterMixin):
class ContactGroupFilter(NestedGroupModelFilter):
parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ContactAssignment, lookups=True)
class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -32,10 +32,10 @@ class TokenSerializer(ValidatedModelSerializer):
model = Token
fields = (
'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires',
'last_used', 'write_enabled', 'pepper_id', 'allowed_ips', 'token',
'last_used', 'enabled', 'write_enabled', 'pepper_id', 'allowed_ips', 'token',
)
read_only_fields = ('key',)
brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description')
brief_fields = ('id', 'url', 'display', 'version', 'key', 'enabled', 'write_enabled', 'description')
def get_fields(self):
fields = super().get_fields()
@@ -79,7 +79,7 @@ class TokenProvisionSerializer(TokenSerializer):
model = Token
fields = (
'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key',
'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token',
'enabled', 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token',
)
def validate(self, data):

View File

@@ -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',
@@ -167,7 +170,8 @@ class TokenFilterSet(BaseFilterSet):
class Meta:
model = Token
fields = (
'id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'created', 'expires', 'last_used',
'id', 'version', 'key', 'pepper_id', 'enabled', 'write_enabled',
'description', 'created', 'expires', 'last_used',
)
def search(self, queryset, name, value):
@@ -180,6 +184,7 @@ class TokenFilterSet(BaseFilterSet):
)
@register_filterset
class ObjectPermissionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -247,6 +252,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
return queryset.exclude(actions__contains=[action])
@register_filterset
class OwnerGroupFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -266,6 +272,7 @@ class OwnerGroupFilterSet(BaseFilterSet):
)
@register_filterset
class OwnerFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -99,6 +99,11 @@ class TokenBulkEditForm(BulkEditForm):
queryset=Token.objects.all(),
widget=forms.MultipleHiddenInput
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label=_('Enabled')
)
write_enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
@@ -122,7 +127,7 @@ class TokenBulkEditForm(BulkEditForm):
model = Token
fieldsets = (
FieldSet('write_enabled', 'description', 'expires', 'allowed_ips'),
FieldSet('enabled', 'write_enabled', 'description', 'expires', 'allowed_ips'),
)
nullable_fields = (
'expires', 'description', 'allowed_ips',

View File

@@ -52,7 +52,7 @@ class TokenImportForm(CSVModelForm):
class Meta:
model = Token
fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',)
fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',)
class OwnerGroupImportForm(CSVModelForm):

View File

@@ -114,7 +114,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
model = Token
fieldsets = (
FieldSet('q', 'filter_id',),
FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')),
FieldSet('version', 'user_id', 'enabled', 'write_enabled', 'expires', 'last_used', name=_('Token')),
)
version = forms.ChoiceField(
choices=add_blank_choice(TokenVersionChoices),
@@ -125,6 +125,13 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('User')
)
enabled = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Enabled'),
)
write_enabled = forms.NullBooleanField(
required=False,
widget=forms.Select(

View File

@@ -140,7 +140,7 @@ class UserTokenForm(forms.ModelForm):
class Meta:
model = Token
fields = [
'version', 'token', 'write_enabled', 'expires', 'description', 'allowed_ips',
'version', 'token', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
@@ -177,7 +177,7 @@ class TokenForm(UserTokenForm):
class Meta(UserTokenForm.Meta):
fields = [
'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips',
'version', 'token', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
def __init__(self, *args, **kwargs):

View File

@@ -5,7 +5,7 @@ import strawberry
import strawberry_django
from strawberry_django import DatetimeFilterLookup, FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin
from netbox.graphql.filters import BaseModelFilter
from users import models
__all__ = (
@@ -17,13 +17,13 @@ __all__ = (
@strawberry_django.filter_type(models.Group, lookups=True)
class GroupFilter(BaseObjectTypeFilterMixin):
class GroupFilter(BaseModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.User, lookups=True)
class UserFilter(BaseObjectTypeFilterMixin):
class UserFilter(BaseModelFilter):
username: FilterLookup[str] | None = strawberry_django.filter_field()
first_name: FilterLookup[str] | None = strawberry_django.filter_field()
last_name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -36,7 +36,7 @@ class UserFilter(BaseObjectTypeFilterMixin):
@strawberry_django.filter_type(models.Owner, lookups=True)
class OwnerFilter(BaseObjectTypeFilterMixin):
class OwnerFilter(BaseModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = (
@@ -49,6 +49,6 @@ class OwnerFilter(BaseObjectTypeFilterMixin):
@strawberry_django.filter_type(models.OwnerGroup, lookups=True)
class OwnerGroupFilter(BaseObjectTypeFilterMixin):
class OwnerGroupFilter(BaseModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -9,6 +9,13 @@ class Migration(migrations.Migration):
]
operations = [
# Add a new field to enable/disable tokens
migrations.AddField(
model_name='token',
name='enabled',
field=models.BooleanField(default=True),
),
# Rename the original key field to "plaintext"
migrations.RenameField(
model_name='token',
@@ -35,7 +42,7 @@ class Migration(migrations.Migration):
),
),
# Add version field to distinguish v1 and v2 tokens
# Add a version field to distinguish v1 and v2 tokens
migrations.AddField(
model_name='token',
name='version',

View File

@@ -61,6 +61,11 @@ class Token(models.Model):
blank=True,
null=True
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
help_text=_('Disable to temporarily revoke this token without deleting it.'),
)
write_enabled = models.BooleanField(
verbose_name=_('write enabled'),
default=True,
@@ -180,6 +185,31 @@ class Token(models.Model):
self.key = self.key or self.generate_key()
self.update_digest()
@property
def is_expired(self):
"""
Check whether the token has expired.
"""
if self.expires is None or timezone.now() < self.expires:
return False
return True
@property
def is_active(self):
"""
Check whether the token is active (enabled and not expired).
"""
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()
@@ -236,12 +266,6 @@ class Token(models.Model):
hashlib.sha256
).hexdigest()
@property
def is_expired(self):
if self.expires is None or timezone.now() < self.expires:
return False
return True
def validate(self, token):
"""
Validate the given plaintext against the token.

View File

@@ -25,6 +25,9 @@ class TokenTable(NetBoxTable):
verbose_name=_('token'),
template_code=TOKEN,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled')
)
write_enabled = columns.BooleanColumn(
verbose_name=_('Write Enabled')
)
@@ -49,10 +52,10 @@ class TokenTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Token
fields = (
'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires',
'last_used', 'allowed_ips',
'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'enabled', 'write_enabled', 'created',
'expires', 'last_used', 'allowed_ips',
)
default_columns = ('token', 'version', 'user', 'write_enabled', 'description', 'allowed_ips')
default_columns = ('token', 'version', 'user', 'enabled', 'write_enabled', 'description', 'allowed_ips')
class UserTable(NetBoxTable):

View File

@@ -195,10 +195,10 @@ class TokenTest(
APIViewTestCases.ListObjectsViewTestCase,
APIViewTestCases.CreateObjectViewTestCase,
APIViewTestCases.UpdateObjectViewTestCase,
APIViewTestCases.DeleteObjectViewTestCase
APIViewTestCases.DeleteObjectViewTestCase,
):
model = Token
brief_fields = ['description', 'display', 'id', 'key', 'url', 'version', 'write_enabled']
brief_fields = ['description', 'display', 'enabled', 'id', 'key', 'url', 'version', 'write_enabled']
bulk_update_data = {
'description': 'New description',
}
@@ -229,12 +229,16 @@ class TokenTest(
cls.create_data = [
{
'user': users[0].pk,
'enabled': True,
},
{
'user': users[1].pk,
'enabled': False,
},
{
'user': users[2].pk,
'enabled': True,
'write_enabled': False,
},
]
@@ -267,6 +271,8 @@ class TokenTest(
self.assertEqual(response.data['expires'], data['expires'])
token = Token.objects.get(user=user)
self.assertEqual(token.key, response.data['key'])
self.assertEqual(token.enabled, response.data['enabled'])
self.assertEqual(token.write_enabled, response.data['write_enabled'])
def test_provision_token_invalid(self):
"""

View File

@@ -285,6 +285,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
version=1,
user=users[0],
expires=future_date,
enabled=True,
write_enabled=True,
description='foobar1',
),
@@ -292,12 +293,14 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
version=2,
user=users[1],
expires=future_date,
enabled=False,
write_enabled=True,
description='foobar2',
),
Token(
version=2,
user=users[2],
enabled=True,
expires=past_date,
write_enabled=False,
),
@@ -339,6 +342,12 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
params = {'expires__lte': '2021-01-01T00:00:00'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_write_enabled(self):
params = {'write_enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -20,6 +20,32 @@ class TokenTest(TestCase):
"""
cls.user = create_test_user('User 1')
def test_is_active(self):
"""
Test the is_active property.
"""
# Token with enabled status and no expiration date
token = Token(user=self.user, enabled=True, expires=None)
self.assertTrue(token.is_active)
# Token with disabled status
token.enabled = False
self.assertFalse(token.is_active)
# Token with enabled status and future expiration
future_date = timezone.now() + timedelta(days=1)
token = Token(user=self.user, enabled=True, expires=future_date)
self.assertTrue(token.is_active)
# Token with past expiration
token.expires = timezone.now() - timedelta(days=1)
self.assertFalse(token.is_active)
# Token with disabled status and past expiration
past_date = timezone.now() - timedelta(days=1)
token = Token(user=self.user, enabled=False, expires=past_date)
self.assertFalse(token.is_active)
def test_is_expired(self):
"""
Test the is_expired property.

View File

@@ -236,13 +236,14 @@ class TokenTestCase(
'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5',
'user': users[0].pk,
'description': 'Test token',
'enabled': True,
}
cls.csv_data = (
"token,user,description",
f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token",
f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token",
f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token",
"token,user,description,enabled,write_enabled",
f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token,true,true",
f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token,true,false",
f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token,false,true",
)
cls.csv_update_data = (

View File

25
netbox/users/ui/panels.py Normal file
View 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')
]

View File

@@ -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)

View 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

View File

@@ -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`.

View File

@@ -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')
)

View File

@@ -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

View File

@@ -1,4 +1,5 @@
from .apiselect import *
from .datetime import *
from .misc import *
from .modifiers import *
from .select import *

View 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

View 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>

View File

@@ -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

View 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())

View File

@@ -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',

View File

@@ -3,11 +3,9 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry import ID
from strawberry.scalars import ID
from strawberry_django import FilterLookup
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin
if TYPE_CHECKING:
from .filters import VirtualMachineFilter
@@ -17,7 +15,7 @@ __all__ = (
@dataclass
class VMComponentFilterMixin(NetBoxModelFilterMixin):
class VMComponentFilterMixin:
virtual_machine: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -7,11 +7,8 @@ from strawberry_django import BaseFilterLookup, FilterLookup
from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
from extras.graphql.filter_mixins import ConfigContextFilterMixin
from netbox.graphql.filter_mixins import (
ImageAttachmentFilterMixin,
OrganizationalModelFilterMixin,
PrimaryModelFilterMixin,
)
from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin
from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization import models
from virtualization.graphql.filter_mixins import VMComponentFilterMixin
@@ -40,7 +37,7 @@ __all__ = (
@strawberry_django.filter_type(models.Cluster, lookups=True)
class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -59,14 +56,14 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P
@strawberry_django.filter_type(models.ClusterGroup, lookups=True)
class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilter):
vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ClusterType, lookups=True)
class ClusterTypeFilter(OrganizationalModelFilterMixin):
class ClusterTypeFilter(OrganizationalModelFilter):
pass
@@ -77,7 +74,7 @@ class VirtualMachineFilter(
RenderConfigFilterMixin,
ConfigContextFilterMixin,
TenancyFilterMixin,
PrimaryModelFilterMixin,
PrimaryModelFilter,
):
name: FilterLookup[str] | None = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
@@ -138,7 +135,7 @@ class VirtualMachineFilter(
@strawberry_django.filter_type(models.VMInterface, lookups=True)
class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin):
class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBoxModelFilter):
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -163,7 +160,7 @@ class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin):
@strawberry_django.filter_type(models.VirtualDisk, lookups=True)
class VirtualDiskFilter(VMComponentFilterMixin):
class VirtualDiskFilter(VMComponentFilterMixin, NetBoxModelFilter):
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)

View File

@@ -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(),

View File

@@ -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)

View File

@@ -5,9 +5,10 @@ import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
from netbox.graphql.filters import (
ChangeLoggedModelFilter, NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from vpn import models
@@ -32,14 +33,12 @@ __all__ = (
@strawberry_django.filter_type(models.TunnelGroup, lookups=True)
class TunnelGroupFilter(OrganizationalModelFilterMixin):
class TunnelGroupFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.TunnelTermination, lookups=True)
class TunnelTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
tunnel_id: ID | None = strawberry_django.filter_field()
role: BaseFilterLookup[Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@@ -59,7 +58,7 @@ class TunnelTerminationFilter(
@strawberry_django.filter_type(models.Tunnel, lookups=True)
class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
strawberry_django.filter_field()
@@ -85,7 +84,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.IKEProposal, lookups=True)
class IKEProposalFilter(PrimaryModelFilterMixin):
class IKEProposalFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
authentication_method: (
BaseFilterLookup[Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')]] | None
@@ -114,7 +113,7 @@ class IKEProposalFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.IKEPolicy, lookups=True)
class IKEPolicyFilter(PrimaryModelFilterMixin):
class IKEPolicyFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
strawberry_django.filter_field()
@@ -129,7 +128,7 @@ class IKEPolicyFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.IPSecProposal, lookups=True)
class IPSecProposalFilter(PrimaryModelFilterMixin):
class IPSecProposalFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
encryption_algorithm: (
BaseFilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None
@@ -155,7 +154,7 @@ class IPSecProposalFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.IPSecPolicy, lookups=True)
class IPSecPolicyFilter(PrimaryModelFilterMixin):
class IPSecPolicyFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -166,7 +165,7 @@ class IPSecPolicyFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.IPSecProfile, lookups=True)
class IPSecProfileFilter(PrimaryModelFilterMixin):
class IPSecProfileFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
strawberry_django.filter_field()
@@ -182,7 +181,7 @@ class IPSecProfileFilter(PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.L2VPN, lookups=True)
class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
type: BaseFilterLookup[Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@@ -206,7 +205,7 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixi
@strawberry_django.filter_type(models.L2VPNTermination, lookups=True)
class L2VPNTerminationFilter(NetBoxModelFilterMixin):
class L2VPNTerminationFilter(NetBoxModelFilter):
l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
l2vpn_id: ID | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (

View File

@@ -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()

View File

@@ -5,18 +5,16 @@ import strawberry
import strawberry_django
from strawberry_django import FilterLookup
from core.graphql.filter_mixins import BaseFilterMixin
if TYPE_CHECKING:
from .enums import *
__all__ = (
'WirelessAuthenticationBaseFilterMixin',
'WirelessAuthenticationFilterMixin',
)
@dataclass
class WirelessAuthenticationBaseFilterMixin(BaseFilterMixin):
class WirelessAuthenticationFilterMixin:
auth_type: Annotated['WirelessAuthTypeEnum', strawberry.lazy('wireless.graphql.enums')] | None = (
strawberry_django.filter_field()
)

View File

@@ -6,10 +6,11 @@ from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from dcim.graphql.filter_mixins import ScopedFilterMixin
from netbox.graphql.filter_mixins import DistanceFilterMixin, PrimaryModelFilterMixin, NestedGroupModelFilterMixin
from netbox.graphql.filter_mixins import DistanceFilterMixin
from netbox.graphql.filters import PrimaryModelFilter, NestedGroupModelFilter
from tenancy.graphql.filter_mixins import TenancyFilterMixin
from wireless import models
from .filter_mixins import WirelessAuthenticationBaseFilterMixin
from .filter_mixins import WirelessAuthenticationFilterMixin
if TYPE_CHECKING:
from dcim.graphql.filters import InterfaceFilter
@@ -24,16 +25,16 @@ __all__ = (
@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True)
class WirelessLANGroupFilter(NestedGroupModelFilterMixin):
class WirelessLANGroupFilter(NestedGroupModelFilter):
pass
@strawberry_django.filter_type(models.WirelessLAN, lookups=True)
class WirelessLANFilter(
WirelessAuthenticationBaseFilterMixin,
WirelessAuthenticationFilterMixin,
ScopedFilterMixin,
TenancyFilterMixin,
PrimaryModelFilterMixin
PrimaryModelFilter
):
ssid: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = (
@@ -49,10 +50,10 @@ class WirelessLANFilter(
@strawberry_django.filter_type(models.WirelessLink, lookups=True)
class WirelessLinkFilter(
WirelessAuthenticationBaseFilterMixin,
WirelessAuthenticationFilterMixin,
DistanceFilterMixin,
TenancyFilterMixin,
PrimaryModelFilterMixin
PrimaryModelFilter
):
interface_a: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()