From a24fbb06ceef6188b3158435b47cd61b096771fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jul 2026 13:16:43 -0400 Subject: [PATCH] Closes #22721: Enable plugins to extend core GraphQL API Co-Authored-By: Claude Opus 4.8 --- docs/plugins/development/graphql-api.md | 92 +++++++++++- docs/plugins/development/index.md | 2 + netbox/circuits/graphql/filters.py | 29 ++-- netbox/circuits/graphql/types.py | 24 ++-- netbox/core/graphql/filters.py | 10 +- netbox/core/graphql/types.py | 11 +- netbox/dcim/graphql/filters.py | 99 ++++++------- netbox/dcim/graphql/types.py | 99 ++++++------- netbox/extras/graphql/filters.py | 36 ++--- netbox/extras/graphql/types.py | 37 +++-- netbox/ipam/graphql/filters.py | 37 ++--- netbox/ipam/graphql/types.py | 44 +++--- netbox/netbox/graphql/filters.py | 12 ++ netbox/netbox/graphql/types.py | 16 ++- netbox/netbox/graphql/utils.py | 126 +++++++++++++++++ netbox/netbox/plugins/__init__.py | 16 +++ netbox/netbox/plugins/registration.py | 72 ++++++++++ netbox/netbox/tests/dummy_plugin/graphql.py | 33 +++++ netbox/netbox/tests/test_graphql.py | 149 ++++++++++++++++++++ netbox/netbox/tests/test_plugins.py | 111 +++++++++++++++ netbox/tenancy/graphql/filters.py | 13 +- netbox/tenancy/graphql/types.py | 21 +-- netbox/users/graphql/filters.py | 10 +- netbox/users/graphql/types.py | 11 +- netbox/virtualization/graphql/filters.py | 16 +-- netbox/virtualization/graphql/types.py | 16 +-- netbox/vpn/graphql/filters.py | 21 +-- netbox/vpn/graphql/types.py | 28 ++-- netbox/wireless/graphql/filters.py | 8 +- netbox/wireless/graphql/types.py | 8 +- 30 files changed, 926 insertions(+), 281 deletions(-) create mode 100644 netbox/netbox/graphql/utils.py diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md index b7691cc70..e596e0f72 100644 --- a/docs/plugins/development/graphql-api.md +++ b/docs/plugins/development/graphql-api.md @@ -25,9 +25,9 @@ class MyModelType: @strawberry.type class MyQuery: @strawberry.field - def dummymodel(self, id: int) -> DummyModelType: + def mymodel(self, id: int) -> MyModelType: return None - dummymodel_list: list[DummyModelType] = strawberry_django.field() + mymodel_list: list[MyModelType] = strawberry_django.field() schema = [ @@ -35,6 +35,94 @@ schema = [ ] ``` +## Extending Core Types & Filters + +!!! info "This feature was introduced in NetBox v4.6." + +In addition to registering its own top-level query fields, a plugin can inject fields and filters onto NetBox's **existing** core GraphQL types (e.g. `DeviceType`). This allows a plugin's related data to be traversed within a single query rooted at a core object, rather than requiring a separate top-level query. This mirrors the `PluginTemplateExtension` mechanism used to extend core object views in the UI. + +An extension is a mixin class declaring a `models` attribute: a list of the lowercased `app_label.model` labels of the core types it extends. Output-type extensions are collected from `graphql.type_extensions` and filter extensions from `graphql.filter_extensions` by default; these paths can be overridden via the `graphql_type_extensions` and `graphql_filter_extensions` attributes on the PluginConfig. + +Each declared path must resolve to a list named `type_extensions` (or `filter_extensions`) - for example, defined in `graphql.py` alongside the schema, or re-exported from the plugin's `graphql` package. + +!!! warning + Do not import core GraphQL modules (e.g. `dcim.graphql.types`) from a plugin's `ready()`. Doing so assembles the affected core types before other plugins have registered their extensions, which are then silently dropped. A warning is logged under `netbox.graphql` if this occurs. + +### Type Extensions + +An output-type extension is a `@strawberry.type` class whose fields and resolvers are spliced into the target type: + +```python +# graphql.py (or graphql/type_extensions.py) +from typing import Annotated + +import strawberry +import strawberry_django + +from utilities.querysets import RestrictedPrefetch +from my_plugin.models import Widget + + +@strawberry.type +class DeviceTypeExtension: + models = ['dcim.device'] + + @strawberry_django.field( + prefetch_related=lambda info: RestrictedPrefetch( + 'widgets', info.context.request.user, 'view', queryset=Widget.objects.all() + ), + ) + def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql.types')]]: + return self.widgets.all() + + +type_extensions = [ + DeviceTypeExtension, +] +``` + +!!! note + Scope any related-object resolver with `RestrictedPrefetch(..., info.context.request.user, 'view', ...)`, as shown above. Object permissions are only applied to the top-level queryset, so a plain `prefetch_related='widgets'` returns related objects the requesting user may not be permitted to see. + +### Filter Extensions + +A filter extension is a `@strawberry.type` class declaring additional filters - either as annotated filter fields or as custom filter methods - which are spliced into the target filter: + +```python +# graphql.py (or graphql/filter_extensions.py) +import strawberry +import strawberry_django +from django.db.models import Q + + +@strawberry.type +class DeviceFilterExtension: + models = ['dcim.device'] + + @strawberry_django.filter_field() + def has_widgets(self, value: bool, prefix) -> Q: + return Q(**{f'{prefix}widgets__isnull': not value}) + + +filter_extensions = [ + DeviceFilterExtension, +] +``` + +With both registered, a client can fetch a device and its plugin-provided data in a single query: + +```graphql +query { + device_list(filters: { has_widgets: true }) { + name + widgets { id name } + } +} +``` + +!!! note + Extensions are strictly additive: they can only add new fields, never replace existing ones. If an extension declares a name the core type already provides, the core definition always takes precedence and the extension's version is ignored. If two extensions on the same type declare the same new name, the one whose plugin is loaded first (earlier in `PLUGINS`) wins. Both cases are logged as warnings under the `netbox.graphql` logger. + ## GraphQL Objects NetBox provides two object type classes for use by plugins. diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index a605bac34..ec85b50c2 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -123,6 +123,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `menu` | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`) | | `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | | `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) | +| `graphql_type_extensions` | The dotted path to the list of GraphQL output-type extension classes, if any (default: `graphql.type_extensions`) | +| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql.filter_extensions`) | | `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 238b488ac..702771190 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -10,7 +10,12 @@ 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 -from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter +from netbox.graphql.filters import ( + ChangeLoggedModelFilter, + OrganizationalModelFilter, + PrimaryModelFilter, + register_filter, +) from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin if TYPE_CHECKING: @@ -36,7 +41,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.CircuitTermination, lookups=True) +@register_filter(models.CircuitTermination, lookups=True) class CircuitTerminationFilter( CustomFieldsFilterMixin, TagsFilterMixin, @@ -83,7 +88,7 @@ class CircuitTerminationFilter( ) -@strawberry_django.filter_type(models.Circuit, lookups=True) +@register_filter(models.Circuit, lookups=True) class CircuitFilter( ContactFilterMixin, ImageAttachmentFilterMixin, @@ -117,17 +122,17 @@ class CircuitFilter( ) -@strawberry_django.filter_type(models.CircuitType, lookups=True) +@register_filter(models.CircuitType, lookups=True) class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.CircuitGroup, lookups=True) +@register_filter(models.CircuitGroup, lookups=True) class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True) +@register_filter(models.CircuitGroupAssignment, lookups=True) class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -142,7 +147,7 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha ) -@strawberry_django.filter_type(models.Provider, lookups=True) +@register_filter(models.Provider, lookups=True) class ProviderFilter(ContactFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -152,7 +157,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.ProviderAccount, lookups=True) +@register_filter(models.ProviderAccount, lookups=True) class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter): provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -162,7 +167,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ProviderNetwork, lookups=True) +@register_filter(models.ProviderNetwork, lookups=True) class ProviderNetworkFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( @@ -172,12 +177,12 @@ class ProviderNetworkFilter(PrimaryModelFilter): service_id: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True) +@register_filter(models.VirtualCircuitType, lookups=True) class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.VirtualCircuit, lookups=True) +@register_filter(models.VirtualCircuit, lookups=True) class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter): cid: StrFilterLookup | None = strawberry_django.filter_field() provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( @@ -200,7 +205,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True) +@register_filter(models.VirtualCircuitTermination, lookups=True) class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index d150d2b5c..09759e931 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -6,7 +6,7 @@ import strawberry_django from circuits import models from dcim.graphql.mixins import CabledObjectMixin from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType, register_type from tenancy.graphql.types import TenantType from .filters import * @@ -30,7 +30,7 @@ __all__ = ( ) -@strawberry_django.type( +@register_type( models.Provider, fields='__all__', filters=ProviderFilter, @@ -43,7 +43,7 @@ class ProviderType(ContactsMixin, PrimaryObjectType): accounts: list[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]] -@strawberry_django.type( +@register_type( models.ProviderAccount, fields='__all__', filters=ProviderAccountFilter, @@ -54,7 +54,7 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType): circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] -@strawberry_django.type( +@register_type( models.ProviderNetwork, fields='__all__', filters=ProviderNetworkFilter, @@ -65,7 +65,7 @@ class ProviderNetworkType(PrimaryObjectType): circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] -@strawberry_django.type( +@register_type( models.CircuitTermination, exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], filters=CircuitTerminationFilter, @@ -86,7 +86,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob return self.termination -@strawberry_django.type( +@register_type( models.CircuitType, fields='__all__', filters=CircuitTypeFilter, @@ -98,7 +98,7 @@ class CircuitTypeType(OrganizationalObjectType): circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] -@strawberry_django.type( +@register_type( models.Circuit, fields='__all__', filters=CircuitFilter, @@ -114,7 +114,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin): terminations: list[CircuitTerminationType] -@strawberry_django.type( +@register_type( models.CircuitGroup, fields='__all__', filters=CircuitGroupFilter, @@ -124,7 +124,7 @@ class CircuitGroupType(OrganizationalObjectType): tenant: TenantType | None -@strawberry_django.type( +@register_type( models.CircuitGroupAssignment, exclude=['member_type', 'member_id'], filters=CircuitGroupAssignmentFilter, @@ -142,7 +142,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): return self.member -@strawberry_django.type( +@register_type( models.VirtualCircuitType, fields='__all__', filters=VirtualCircuitTypeFilter, @@ -154,7 +154,7 @@ class VirtualCircuitTypeType(OrganizationalObjectType): virtual_circuits: list[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]] -@strawberry_django.type( +@register_type( models.VirtualCircuitTermination, fields='__all__', filters=VirtualCircuitTerminationFilter, @@ -171,7 +171,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): ] = strawberry_django.field(select_related=["interface"]) -@strawberry_django.type( +@register_type( models.VirtualCircuit, fields='__all__', filters=VirtualCircuitFilter, diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 05f4803e4..b1e70ef45 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -7,7 +7,7 @@ from strawberry.scalars import ID from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup from core import models -from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter +from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter, register_filter from .enums import * @@ -23,7 +23,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.DataFile, lookups=True) +@register_filter(models.DataFile, lookups=True) class DataFileFilter(BaseModelFilter): created: DatetimeFilterLookup | None = strawberry_django.filter_field() last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field() @@ -38,7 +38,7 @@ class DataFileFilter(BaseModelFilter): hash: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.DataSource, lookups=True) +@register_filter(models.DataSource, lookups=True) class DataSourceFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() type: StrFilterLookup | None = strawberry_django.filter_field() @@ -57,7 +57,7 @@ class DataSourceFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.ObjectChange, lookups=True) +@register_filter(models.ObjectChange, lookups=True) class ObjectChangeFilter(BaseModelFilter): time: DatetimeFilterLookup | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() @@ -84,7 +84,7 @@ class ObjectChangeFilter(BaseModelFilter): ) -@strawberry_django.filter_type(DjangoContentType, lookups=True) +@register_filter(DjangoContentType, lookups=True) class ContentTypeFilter(BaseModelFilter): app_label: StrFilterLookup | None = strawberry_django.filter_field() model: StrFilterLookup | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 5a5d043d5..d48f478ce 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -1,11 +1,10 @@ from typing import Annotated import strawberry -import strawberry_django from django.contrib.contenttypes.models import ContentType as DjangoContentType from core import models -from netbox.graphql.types import BaseObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, PrimaryObjectType, register_type from .filters import * @@ -17,7 +16,7 @@ __all__ = ( ) -@strawberry_django.type( +@register_type( models.DataFile, exclude=['data',], filters=DataFileFilter, @@ -27,7 +26,7 @@ class DataFileType(BaseObjectType): source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] -@strawberry_django.type( +@register_type( models.DataSource, fields='__all__', filters=DataSourceFilter, @@ -37,7 +36,7 @@ class DataSourceType(PrimaryObjectType): datafiles: list[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]] -@strawberry_django.type( +@register_type( models.ObjectChange, fields='__all__', filters=ObjectChangeFilter, @@ -47,7 +46,7 @@ class ObjectChangeType(BaseObjectType): pass -@strawberry_django.type( +@register_type( DjangoContentType, fields='__all__', pagination=True diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 197257531..67a8ed429 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -31,6 +31,7 @@ from netbox.graphql.filters import ( NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, + register_filter, ) from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin from virtualization.models import VMInterface @@ -121,12 +122,12 @@ __all__ = ( ) -@strawberry_django.filter_type(models.CableBundle, lookups=True) +@register_filter(models.CableBundle, lookups=True) class CableBundleFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Cable, lookups=True) +@register_filter(models.Cable, lookups=True) class CableFilter(TenancyFilterMixin, PrimaryModelFilter): type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -149,7 +150,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.CableTermination, lookups=True) +@register_filter(models.CableTermination, lookups=True) class CableTerminationFilter(ChangeLoggedModelFilter): cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() cable_id: ID | None = strawberry_django.filter_field() @@ -176,7 +177,7 @@ class CableTerminationFilter(ChangeLoggedModelFilter): ) -@strawberry_django.filter_type(models.ConsolePort, lookups=True) +@register_filter(models.ConsolePort, lookups=True) class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -186,14 +187,14 @@ class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi ) -@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True) +@register_filter(models.ConsolePortTemplate, lookups=True) 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) +@register_filter(models.ConsoleServerPort, lookups=True) class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -203,14 +204,14 @@ class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilt ) -@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True) +@register_filter(models.ConsoleServerPortTemplate, lookups=True) class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter_type(models.Device, lookups=True) +@register_filter(models.Device, lookups=True) class DeviceFilter( ContactFilterMixin, TenancyFilterMixin, @@ -329,7 +330,7 @@ class DeviceFilter( inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.DeviceBay, lookups=True) +@register_filter(models.DeviceBay, lookups=True) class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter): enabled: FilterLookup[bool] | None = strawberry_django.filter_field() installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -338,12 +339,12 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter): installed_device_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True) +@register_filter(models.DeviceBayTemplate, lookups=True) class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter): enabled: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True) +@register_filter(models.InventoryItemTemplate, lookups=True) class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter): parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -363,7 +364,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode part_id: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.DeviceRole, lookups=True) +@register_filter(models.DeviceRole, lookups=True) class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter): color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -371,7 +372,7 @@ class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter): vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.DeviceType, lookups=True) +@register_filter(models.DeviceType, lookups=True) class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -448,7 +449,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.FrontPort, lookups=True) +@register_filter(models.FrontPort, lookups=True) class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -458,7 +459,7 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, ) -@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) +@register_filter(models.FrontPortTemplate, lookups=True) class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -468,7 +469,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM ) -@strawberry_django.filter_type(models.PortMapping, lookups=True) +@register_filter(models.PortMapping, lookups=True) 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 = ( @@ -481,7 +482,7 @@ class PortMappingFilter(BaseModelFilter): rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True) +@register_filter(models.PortTemplateMapping, lookups=True) class PortTemplateMappingFilter(BaseModelFilter): device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -499,7 +500,7 @@ class PortTemplateMappingFilter(BaseModelFilter): rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.MACAddress, lookups=True) +@register_filter(models.MACAddress, lookups=True) class MACAddressFilter(PrimaryModelFilter): mac_address: StrFilterLookup | None = strawberry_django.filter_field() assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -525,7 +526,7 @@ class MACAddressFilter(PrimaryModelFilter): return ~Q(query) -@strawberry_django.filter_type(models.Interface, lookups=True) +@register_filter(models.Interface, lookups=True) class InterfaceFilter( ModularComponentFilterMixin, InterfaceBaseFilterMixin, @@ -630,7 +631,7 @@ class InterfaceFilter( return queryset, Q() -@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True) +@register_filter(models.InterfaceTemplate, lookups=True) class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -662,7 +663,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM ) -@strawberry_django.filter_type(models.InventoryItem, lookups=True) +@register_filter(models.InventoryItem, lookups=True) class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter): parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -689,14 +690,14 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter): discovered: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.InventoryItemRole, lookups=True) +@register_filter(models.InventoryItemRole, lookups=True) 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) +@register_filter(models.Location, lookups=True) 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() @@ -712,12 +713,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt ) -@strawberry_django.filter_type(models.Manufacturer, lookups=True) +@register_filter(models.Manufacturer, lookups=True) class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.Module, lookups=True) +@register_filter(models.Module, lookups=True) 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() @@ -766,7 +767,7 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.ModuleBay, lookups=True) +@register_filter(models.ModuleBay, lookups=True) class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter): parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -780,7 +781,7 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter): module_bay_type_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True) +@register_filter(models.ModuleBayTemplate, lookups=True) class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): position: StrFilterLookup | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -790,7 +791,7 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM module_bay_type_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ModuleBayType, lookups=True) +@register_filter(models.ModuleBayType, lookups=True) class ModuleBayTypeFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -800,12 +801,12 @@ class ModuleBayTypeFilter(PrimaryModelFilter): manufacturer_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True) +@register_filter(models.ModuleTypeProfile, lookups=True) class ModuleTypeProfileFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ModuleType, lookups=True) +@register_filter(models.ModuleType, lookups=True) class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -858,7 +859,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Platform, lookups=True) +@register_filter(models.Platform, lookups=True) class PlatformFilter(OrganizationalModelFilter): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -870,7 +871,7 @@ class PlatformFilter(OrganizationalModelFilter): config_template_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.PowerFeed, lookups=True) +@register_filter(models.PowerFeed, lookups=True) class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilter): power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -905,7 +906,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM ) -@strawberry_django.filter_type(models.PowerOutlet, lookups=True) +@register_filter(models.PowerOutlet, lookups=True) class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -925,7 +926,7 @@ class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi ) -@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) +@register_filter(models.PowerOutletTemplate, lookups=True) class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -939,7 +940,7 @@ class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLogge ) -@strawberry_django.filter_type(models.PowerPanel, lookups=True) +@register_filter(models.PowerPanel, lookups=True) 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() @@ -952,7 +953,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.PowerPort, lookups=True) +@register_filter(models.PowerPort, lookups=True) class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -965,7 +966,7 @@ class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, ) -@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True) +@register_filter(models.PowerPortTemplate, lookups=True) class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -978,7 +979,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM ) -@strawberry_django.filter_type(models.RackType, lookups=True) +@register_filter(models.RackType, lookups=True) class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter): form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -993,7 +994,7 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Rack, lookups=True) +@register_filter(models.Rack, lookups=True) class RackFilter( ContactFilterMixin, ImageAttachmentFilterMixin, @@ -1038,12 +1039,12 @@ class RackFilter( ) -@strawberry_django.filter_type(models.RackGroup, lookups=True) +@register_filter(models.RackGroup, lookups=True) class RackGroupFilter(OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.RackReservation, lookups=True) +@register_filter(models.RackReservation, lookups=True) 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() @@ -1059,14 +1060,14 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.RackRole, lookups=True) +@register_filter(models.RackRole, lookups=True) 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) +@register_filter(models.RearPort, lookups=True) class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter): type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -1079,7 +1080,7 @@ class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, ) -@strawberry_django.filter_type(models.RearPortTemplate, lookups=True) +@register_filter(models.RearPortTemplate, lookups=True) class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -1092,7 +1093,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedMo ) -@strawberry_django.filter_type(models.Region, lookups=True) +@register_filter(models.Region, lookups=True) class RegionFilter(ContactFilterMixin, NestedGroupModelFilter): prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -1102,7 +1103,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter): ) -@strawberry_django.filter_type(models.Site, lookups=True) +@register_filter(models.Site, lookups=True) class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -1138,7 +1139,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi ) -@strawberry_django.filter_type(models.SiteGroup, lookups=True) +@register_filter(models.SiteGroup, lookups=True) class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter): prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -1148,7 +1149,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter): ) -@strawberry_django.filter_type(models.VirtualChassis, lookups=True) +@register_filter(models.VirtualChassis, lookups=True) class VirtualChassisFilter(PrimaryModelFilter): master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() master_id: ID | None = strawberry_django.filter_field() @@ -1160,7 +1161,7 @@ class VirtualChassisFilter(PrimaryModelFilter): member_count: FilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True) +@register_filter(models.VirtualDeviceContext, lookups=True) 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() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index fc42ce2f0..a0af425c2 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -17,6 +17,7 @@ from netbox.graphql.types import ( NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType, + register_type, ) from users.graphql.mixins import OwnerMixin from utilities.querysets import RestrictedPrefetch @@ -134,7 +135,7 @@ class ModularComponentTemplateType(ComponentTemplateType): # -@strawberry_django.type( +@register_type( models.CableBundle, fields='__all__', filters=CableBundleFilter, @@ -144,7 +145,7 @@ class CableBundleType(PrimaryObjectType): cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.CableTermination, exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], filters=CableTerminationFilter, @@ -166,7 +167,7 @@ class CableTerminationType(NetBoxObjectType): ] | None -@strawberry_django.type( +@register_type( models.Cable, fields='__all__', filters=CableFilter, @@ -206,7 +207,7 @@ class CableType(PrimaryObjectType): ]] -@strawberry_django.type( +@register_type( models.ConsolePort, exclude=['_path'], filters=ConsolePortFilter, @@ -216,7 +217,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin pass -@strawberry_django.type( +@register_type( models.ConsolePortTemplate, fields='__all__', filters=ConsolePortTemplateFilter, @@ -226,7 +227,7 @@ class ConsolePortTemplateType(ModularComponentTemplateType): pass -@strawberry_django.type( +@register_type( models.ConsoleServerPort, exclude=['_path'], filters=ConsoleServerPortFilter, @@ -236,7 +237,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin pass -@strawberry_django.type( +@register_type( models.ConsoleServerPortTemplate, fields='__all__', filters=ConsoleServerPortTemplateFilter, @@ -246,7 +247,7 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): pass -@strawberry_django.type( +@register_type( models.Device, fields='__all__', filters=DeviceFilter, @@ -302,7 +303,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima return self.parent_bay if hasattr(self, 'parent_bay') else None -@strawberry_django.type( +@register_type( models.DeviceBay, fields='__all__', filters=DeviceBayFilter, @@ -312,7 +313,7 @@ class DeviceBayType(ComponentType): installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None -@strawberry_django.type( +@register_type( models.DeviceBayTemplate, fields='__all__', filters=DeviceBayTemplateFilter, @@ -322,7 +323,7 @@ class DeviceBayTemplateType(ComponentTemplateType): pass -@strawberry_django.type( +@register_type( models.InventoryItemTemplate, exclude=['component_type', 'component_id', 'parent', 'path'], filters=InventoryItemTemplateFilter, @@ -350,7 +351,7 @@ class InventoryItemTemplateType(LtreeNodeMixin, ComponentTemplateType): ] | None -@strawberry_django.type( +@register_type( models.DeviceRole, exclude=['path', 'sort_path'], filters=DeviceRoleFilter, @@ -366,7 +367,7 @@ class DeviceRoleType(NestedLtreeGroupObjectType): devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.DeviceType, fields='__all__', filters=DeviceTypeFilter, @@ -402,7 +403,7 @@ class DeviceTypeType(PrimaryObjectType): consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.FrontPort, fields='__all__', filters=FrontPortFilter, @@ -414,7 +415,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.FrontPortTemplate, fields='__all__', filters=FrontPortTemplateFilter, @@ -426,7 +427,7 @@ class FrontPortTemplateType(ModularComponentTemplateType): mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.MACAddress, exclude=['assigned_object_type', 'assigned_object_id'], filters=MACAddressFilter, @@ -444,7 +445,7 @@ class MACAddressType(PrimaryObjectType): return self.assigned_object -@strawberry_django.type( +@register_type( models.Interface, exclude=['_path'], filters=InterfaceFilter, @@ -474,7 +475,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.InterfaceTemplate, fields='__all__', filters=InterfaceTemplateFilter, @@ -489,7 +490,7 @@ class InterfaceTemplateType(ModularComponentTemplateType): child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.InventoryItem, exclude=['component_type', 'component_id', 'parent', 'path'], filters=InventoryItemFilter, @@ -517,7 +518,7 @@ class InventoryItemType(LtreeNodeMixin, ComponentType): ] | None -@strawberry_django.type( +@register_type( models.InventoryItemRole, fields='__all__', filters=InventoryItemRoleFilter, @@ -530,7 +531,7 @@ class InventoryItemRoleType(OrganizationalObjectType): inventory_item_templates: list[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.Location, # fields='__all__', exclude=['parent', 'path', 'sort_path'], # bug - temp @@ -568,7 +569,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Nested return self.circuit_terminations.all() -@strawberry_django.type( +@register_type( models.Manufacturer, fields='__all__', filters=ManufacturerFilter, @@ -583,7 +584,7 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin): module_types: list[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.Module, fields='__all__', filters=ModuleFilter, @@ -603,7 +604,7 @@ class ModuleType(PrimaryObjectType): frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ModuleBay, # fields='__all__', exclude=['parent', 'path', 'sort_path'], @@ -621,7 +622,7 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType): return self.parent -@strawberry_django.type( +@register_type( models.ModuleBayTemplate, fields='__all__', filters=ModuleBayTemplateFilter, @@ -631,7 +632,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType): module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ModuleBayType, fields='__all__', filters=ModuleBayTypeFilter, @@ -645,7 +646,7 @@ class ModuleBayTypeType(PrimaryObjectType): module_bay_templates: list[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ModuleTypeProfile, fields='__all__', filters=ModuleTypeProfileFilter, @@ -655,7 +656,7 @@ class ModuleTypeProfileType(PrimaryObjectType): module_types: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ModuleType, fields='__all__', filters=ModuleTypeFilter, @@ -685,7 +686,7 @@ class ModuleTypeType(PrimaryObjectType): consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.Platform, exclude=['path', 'sort_path'], filters=PlatformFilter, @@ -701,7 +702,7 @@ class PlatformType(NestedLtreeGroupObjectType): devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.PortMapping, fields='__all__', filters=PortMappingFilter, @@ -712,7 +713,7 @@ class PortMappingType(ModularComponentTemplateType): rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] -@strawberry_django.type( +@register_type( models.PortTemplateMapping, fields='__all__', filters=PortTemplateMappingFilter, @@ -723,7 +724,7 @@ class PortMappingTemplateType(ModularComponentTemplateType): rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] -@strawberry_django.type( +@register_type( models.PowerFeed, exclude=['_path'], filters=PowerFeedFilter, @@ -735,7 +736,7 @@ class PowerFeedType(CabledObjectMixin, PathEndpointMixin, PrimaryObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None -@strawberry_django.type( +@register_type( models.PowerOutlet, exclude=['_path'], filters=PowerOutletFilter, @@ -746,7 +747,7 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin color: str -@strawberry_django.type( +@register_type( models.PowerOutletTemplate, fields='__all__', filters=PowerOutletTemplateFilter, @@ -757,7 +758,7 @@ class PowerOutletTemplateType(ModularComponentTemplateType): color: str -@strawberry_django.type( +@register_type( models.PowerPanel, fields='__all__', filters=PowerPanelFilter, @@ -770,7 +771,7 @@ class PowerPanelType(ContactsMixin, PrimaryObjectType): powerfeeds: list[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.PowerPort, exclude=['_path'], filters=PowerPortFilter, @@ -781,7 +782,7 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): poweroutlets: list[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.PowerPortTemplate, fields='__all__', filters=PowerPortTemplateFilter, @@ -791,7 +792,7 @@ class PowerPortTemplateType(ModularComponentTemplateType): poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.RackGroup, fields='__all__', filters=RackGroupFilter, @@ -802,7 +803,7 @@ class RackGroupType(OrganizationalObjectType): racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.RackType, fields='__all__', filters=RackTypeFilter, @@ -813,7 +814,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] -@strawberry_django.type( +@register_type( models.Rack, fields='__all__', filters=RackFilter, @@ -833,7 +834,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj cabletermination_set: list[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.RackReservation, fields='__all__', filters=RackReservationFilter, @@ -857,7 +858,7 @@ class RackReservationType(PrimaryObjectType): return len(self.units) -@strawberry_django.type( +@register_type( models.RackRole, fields='__all__', filters=RackRoleFilter, @@ -869,7 +870,7 @@ class RackRoleType(OrganizationalObjectType): racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.RearPort, fields='__all__', filters=RearPortFilter, @@ -881,7 +882,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin): mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.RearPortTemplate, fields='__all__', filters=RearPortTemplateFilter, @@ -893,7 +894,7 @@ class RearPortTemplateType(ModularComponentTemplateType): mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.Region, exclude=['parent', 'path', 'sort_path'], filters=RegionFilter, @@ -928,7 +929,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType): return self.circuit_terminations.all() -@strawberry_django.type( +@register_type( models.Site, fields='__all__', filters=SiteFilter, @@ -970,7 +971,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj return self.circuit_terminations.all() -@strawberry_django.type( +@register_type( models.SiteGroup, exclude=['parent', 'path', 'sort_path'], # bug - temp filters=SiteGroupFilter, @@ -1005,7 +1006,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType): return self.circuit_terminations.all() -@strawberry_django.type( +@register_type( models.VirtualChassis, fields='__all__', filters=VirtualChassisFilter, @@ -1018,7 +1019,7 @@ class VirtualChassisType(PrimaryObjectType): members: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.VirtualDeviceContext, fields='__all__', filters=VirtualDeviceContextFilter, diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 779519789..b353dbfbc 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -9,7 +9,7 @@ from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLook from extras import models from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin from netbox.graphql.filter_mixins import SyncedDataFilterMixin -from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter +from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter, register_filter if TYPE_CHECKING: from core.graphql.filters import ContentTypeFilter @@ -59,7 +59,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.ConfigContext, lookups=True) +@register_filter(models.ConfigContext, lookups=True) class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -116,14 +116,14 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): ) -@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True) +@register_filter(models.ConfigContextProfile, lookups=True) class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = 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) +@register_filter(models.ConfigTemplate, lookups=True) class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -137,7 +137,7 @@ class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.CustomField, lookups=True) +@register_filter(models.CustomField, lookups=True) class CustomFieldFilter(ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -197,7 +197,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter): comments: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True) +@register_filter(models.CustomFieldChoiceSet, lookups=True) class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -239,7 +239,7 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter): return queryset, params -@strawberry_django.filter_type(models.CustomLink, lookups=True) +@register_filter(models.CustomLink, lookups=True) class CustomLinkFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -257,7 +257,7 @@ class CustomLinkFilter(ChangeLoggedModelFilter): new_window: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ExportTemplate, lookups=True) +@register_filter(models.ExportTemplate, lookups=True) class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -271,7 +271,7 @@ class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ImageAttachment, lookups=True) +@register_filter(models.ImageAttachment, lookups=True) class ImageAttachmentFilter(ChangeLoggedModelFilter): object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -289,7 +289,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.JournalEntry, lookups=True) +@register_filter(models.JournalEntry, lookups=True) class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -305,7 +305,7 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM comments: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Notification, lookups=True) +@register_filter(models.Notification, lookups=True) class NotificationFilter(BaseModelFilter): created: DatetimeFilterLookup | None = strawberry_django.filter_field() read: DatetimeFilterLookup | None = strawberry_django.filter_field() @@ -320,7 +320,7 @@ class NotificationFilter(BaseModelFilter): event_type: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.NotificationGroup, lookups=True) +@register_filter(models.NotificationGroup, lookups=True) class NotificationGroupFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -328,7 +328,7 @@ class NotificationGroupFilter(ChangeLoggedModelFilter): users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.SavedFilter, lookups=True) +@register_filter(models.SavedFilter, lookups=True) class SavedFilterFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -345,7 +345,7 @@ class SavedFilterFilter(ChangeLoggedModelFilter): ) -@strawberry_django.filter_type(models.Subscription, lookups=True) +@register_filter(models.Subscription, lookups=True) class SubscriptionFilter(BaseModelFilter): created: DatetimeFilterLookup | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() @@ -357,7 +357,7 @@ class SubscriptionFilter(BaseModelFilter): object_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.TableConfig, lookups=True) +@register_filter(models.TableConfig, lookups=True) class TableConfigFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -370,7 +370,7 @@ class TableConfigFilter(ChangeLoggedModelFilter): shared: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Tag, lookups=True) +@register_filter(models.Tag, lookups=True) class TagFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -380,7 +380,7 @@ class TagFilter(ChangeLoggedModelFilter): description: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Webhook, lookups=True) +@register_filter(models.Webhook, lookups=True) class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -401,7 +401,7 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF ) -@strawberry_django.filter_type(models.EventRule, lookups=True) +@register_filter(models.EventRule, lookups=True) class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 832b3b974..99deb43e4 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,14 +1,13 @@ from typing import TYPE_CHECKING, Annotated import strawberry -import strawberry_django from strawberry.scalars import JSON from strawberry.types import Info from core.graphql.mixins import SyncedDataMixin from extras import models from extras.graphql.mixins import CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType, register_type from users.graphql.mixins import OwnerMixin from .filters import * @@ -60,7 +59,7 @@ class SharedObjectMixin: return queryset.restrict_to_shared(info.context.request.user) -@strawberry_django.type( +@register_type( models.ConfigContextProfile, fields='__all__', filters=ConfigContextProfileFilter, @@ -70,7 +69,7 @@ class ConfigContextProfileType(SyncedDataMixin, PrimaryObjectType): pass -@strawberry_django.type( +@register_type( models.ConfigContext, fields='__all__', filters=ConfigContextFilter, @@ -93,7 +92,7 @@ class ConfigContextType(SyncedDataMixin, OwnerMixin, ObjectType): site_groups: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ConfigTemplate, fields='__all__', filters=ConfigTemplateFilter, @@ -106,7 +105,7 @@ class ConfigTemplateType(SyncedDataMixin, OwnerMixin, TagsMixin, ObjectType): device_roles: list[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.CustomField, fields='__all__', filters=CustomFieldFilter, @@ -117,7 +116,7 @@ class CustomFieldType(OwnerMixin, ObjectType): choice_set: Annotated["CustomFieldChoiceSetType", strawberry.lazy('extras.graphql.types')] | None -@strawberry_django.type( +@register_type( models.CustomFieldChoiceSet, exclude=['extra_choices', 'choice_colors'], filters=CustomFieldChoiceSetFilter, @@ -130,7 +129,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType): choice_colors: JSON -@strawberry_django.type( +@register_type( models.CustomLink, fields='__all__', filters=CustomLinkFilter, @@ -140,7 +139,7 @@ class CustomLinkType(OwnerMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.ExportTemplate, fields='__all__', filters=ExportTemplateFilter, @@ -150,7 +149,7 @@ class ExportTemplateType(SyncedDataMixin, OwnerMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.ImageAttachment, fields='__all__', filters=ImageAttachmentFilter, @@ -160,7 +159,7 @@ class ImageAttachmentType(BaseObjectType): object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None -@strawberry_django.type( +@register_type( models.JournalEntry, fields='__all__', filters=JournalEntryFilter, @@ -171,7 +170,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Notification, filters=NotificationFilter, pagination=True @@ -180,7 +179,7 @@ class NotificationType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.NotificationGroup, filters=NotificationGroupFilter, pagination=True @@ -190,7 +189,7 @@ class NotificationGroupType(ObjectType): groups: list[Annotated["GroupType", strawberry.lazy('users.graphql.types')]] -@strawberry_django.type( +@register_type( models.SavedFilter, exclude=['content_types',], filters=SavedFilterFilter, @@ -200,7 +199,7 @@ class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Subscription, filters=SubscriptionFilter, pagination=True @@ -209,7 +208,7 @@ class SubscriptionType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.TableConfig, fields='__all__', filters=TableConfigFilter, @@ -220,7 +219,7 @@ class TableConfigType(SharedObjectMixin, ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Tag, exclude=['extras_taggeditem_items', ], filters=TagFilter, @@ -232,7 +231,7 @@ class TagType(OwnerMixin, ObjectType): object_types: list[ContentTypeType] -@strawberry_django.type( +@register_type( models.Webhook, exclude=['content_types',], filters=WebhookFilter, @@ -242,7 +241,7 @@ class WebhookType(OwnerMixin, CustomFieldsMixin, TagsMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.EventRule, exclude=['content_types',], filters=EventRuleFilter, diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 7d16c0fe6..6d72d7d8f 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -17,6 +17,7 @@ from netbox.graphql.filters import ( NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, + register_filter, ) from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin from virtualization.models import VMInterface @@ -52,7 +53,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.ASN, lookups=True) +@register_filter(models.ASN, lookups=True) 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() @@ -69,7 +70,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter): ) = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ASNRange, lookups=True) +@register_filter(models.ASNRange, lookups=True) class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -83,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter): ) -@strawberry_django.filter_type(models.Aggregate, lookups=True) +@register_filter(models.Aggregate, lookups=True) class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): prefix: StrFilterLookup | None = strawberry_django.filter_field() rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -116,7 +117,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter return Q(**{f"{prefix}prefix__family": value.value}) -@strawberry_django.filter_type(models.FHRPGroup, lookups=True) +@register_filter(models.FHRPGroup, lookups=True) class FHRPGroupFilter(PrimaryModelFilter): group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() @@ -134,7 +135,7 @@ class FHRPGroupFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True) +@register_filter(models.FHRPGroupAssignment, lookups=True) class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter): interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -173,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter): return Q(**{f"{prefix}interface_id__in": interface_ids}) -@strawberry_django.filter_type(models.IPAddress, lookups=True) +@register_filter(models.IPAddress, lookups=True) class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): address: StrFilterLookup | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -224,7 +225,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter return Q(**{f"{prefix}address__family": value.value}) -@strawberry_django.filter_type(models.IPRange, lookups=True) +@register_filter(models.IPRange, lookups=True) class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): start_address: StrFilterLookup | None = strawberry_django.filter_field() end_address: StrFilterLookup | None = strawberry_django.filter_field() @@ -278,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): return q -@strawberry_django.filter_type(models.Prefix, lookups=True) +@register_filter(models.Prefix, lookups=True) class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter): prefix: StrFilterLookup | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -315,19 +316,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr return Q(**{f"{prefix}prefix__family": value.value}) -@strawberry_django.filter_type(models.RIR, lookups=True) +@register_filter(models.RIR, lookups=True) class RIRFilter(OrganizationalModelFilter): is_private: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Role, lookups=True) +@register_filter(models.Role, lookups=True) 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) +@register_filter(models.RouteTarget, lookups=True) class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -344,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.Service, lookups=True) +@register_filter(models.Service, lookups=True) class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -356,12 +357,12 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter): parent_object_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ServiceTemplate, lookups=True) +@register_filter(models.ServiceTemplate, lookups=True) class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VLAN, lookups=True) +@register_filter(models.VLAN, lookups=True) 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() @@ -393,7 +394,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.VLANGroup, lookups=True) +@register_filter(models.VLANGroup, lookups=True) class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter): vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() @@ -401,12 +402,12 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter): total_vlan_ids: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) +@register_filter(models.VLANTranslationPolicy, lookups=True) class VLANTranslationPolicyFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True) +@register_filter(models.VLANTranslationRule, lookups=True) class VLANTranslationRuleFilter(NetBoxModelFilter): policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -421,7 +422,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter): ) -@strawberry_django.filter_type(models.VRF, lookups=True) +@register_filter(models.VRF, lookups=True) class VRFFilter(TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() rd: StrFilterLookup | None = strawberry_django.filter_field() diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index f17c9c72e..6bbaafeb8 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -8,7 +8,13 @@ from dcim.graphql.types import SiteType from extras.graphql.mixins import ContactsMixin from ipam import models from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import ( + BaseObjectType, + NetBoxObjectType, + OrganizationalObjectType, + PrimaryObjectType, + register_type, +) from .filters import * from .mixins import IPAddressesMixin @@ -68,7 +74,7 @@ class BaseIPAddressFamilyType: return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}') -@strawberry_django.type( +@register_type( models.ASN, fields='__all__', filters=ASNFilter, @@ -84,7 +90,7 @@ class ASNType(ContactsMixin, PrimaryObjectType): providers: list[ProviderType] -@strawberry_django.type( +@register_type( models.ASNRange, fields='__all__', filters=ASNRangeFilter, @@ -97,7 +103,7 @@ class ASNRangeType(OrganizationalObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Aggregate, fields='__all__', filters=AggregateFilter, @@ -109,7 +115,7 @@ class AggregateType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None -@strawberry_django.type( +@register_type( models.FHRPGroup, fields='__all__', filters=FHRPGroupFilter, @@ -119,7 +125,7 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType): fhrpgroupassignment_set: list[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.FHRPGroupAssignment, exclude=['interface_type', 'interface_id'], filters=FHRPGroupAssignmentFilter, @@ -137,7 +143,7 @@ class FHRPGroupAssignmentType(BaseObjectType): return self.interface -@strawberry_django.type( +@register_type( models.IPAddress, exclude=['assigned_object_type', 'assigned_object_id', 'address'], filters=IPAddressFilter, @@ -163,7 +169,7 @@ class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType): return self.assigned_object -@strawberry_django.type( +@register_type( models.IPRange, fields='__all__', filters=IPRangeFilter, @@ -177,7 +183,7 @@ class IPRangeType(ContactsMixin, PrimaryObjectType): role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Prefix, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=PrefixFilter, @@ -201,7 +207,7 @@ class PrefixType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType): return self.scope -@strawberry_django.type( +@register_type( models.RIR, fields='__all__', filters=RIRFilter, @@ -214,7 +220,7 @@ class RIRType(OrganizationalObjectType): aggregates: list[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.Role, fields='__all__', filters=RoleFilter, @@ -227,7 +233,7 @@ class RoleType(OrganizationalObjectType): vlans: list[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.RouteTarget, fields='__all__', filters=RouteTargetFilter, @@ -242,7 +248,7 @@ class RouteTargetType(PrimaryObjectType): exporting_vrfs: list[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.Service, exclude=('_ports_lowest', 'parent_object_type', 'parent_object_id'), filters=ServiceFilter, @@ -262,7 +268,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType): return self.parent -@strawberry_django.type( +@register_type( models.ServiceTemplate, exclude=('_ports_lowest',), filters=ServiceTemplateFilter, @@ -272,7 +278,7 @@ class ServiceTemplateType(PrimaryObjectType): ports: list[int] -@strawberry_django.type( +@register_type( models.VLAN, exclude=['qinq_svlan'], filters=VLANFilter, @@ -296,7 +302,7 @@ class VLANType(PrimaryObjectType): return self.qinq_svlan -@strawberry_django.type( +@register_type( models.VLANGroup, exclude=['scope_type', 'scope_id'], filters=VLANGroupFilter, @@ -323,7 +329,7 @@ class VLANGroupType(OrganizationalObjectType): return self.scope -@strawberry_django.type( +@register_type( models.VLANTranslationPolicy, fields='__all__', filters=VLANTranslationPolicyFilter, @@ -333,7 +339,7 @@ class VLANTranslationPolicyType(PrimaryObjectType): rules: list[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.VLANTranslationRule, fields='__all__', filters=VLANTranslationRuleFilter, @@ -346,7 +352,7 @@ class VLANTranslationRuleType(NetBoxObjectType): ] = strawberry_django.field(select_related=["policy"]) -@strawberry_django.type( +@register_type( models.VRF, fields='__all__', filters=VRFFilter, diff --git a/netbox/netbox/graphql/filters.py b/netbox/netbox/graphql/filters.py index c6241e226..7a215f03a 100644 --- a/netbox/netbox/graphql/filters.py +++ b/netbox/netbox/graphql/filters.py @@ -7,6 +7,7 @@ from strawberry_django import ComparisonFilterLookup, StrFilterLookup from core.graphql.filter_mixins import ChangeLoggingMixin from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin +from netbox.graphql.utils import register_model_graphql_type if TYPE_CHECKING: from .filters import * @@ -18,9 +19,20 @@ __all__ = ( 'NetBoxModelFilter', 'OrganizationalModelFilter', 'PrimaryModelFilter', + 'register_filter', ) +def register_filter(model, **kwargs): + """ + Drop-in replacement for `strawberry_django.filter_type()` for model-bound NetBox GraphQL filters. Before + delegating to `strawberry_django.filter_type()`, any plugin-registered filter mixins for the given model are + spliced into the decorated class's bases. With no extensions registered this is an exact pass-through, leaving + schema output unchanged. See `register_model_graphql_type` for the registry-timing contract. + """ + return register_model_graphql_type(model, strawberry_django.filter_type, 'graphql_filter_extensions', **kwargs) + + @dataclass class BaseModelFilter: id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field() diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 96ac3e73a..d18efe475 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -7,6 +7,7 @@ from strawberry.types import Info from core.graphql.mixins import ChangelogMixin from core.models import ObjectType as ObjectType_ from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin +from netbox.graphql.utils import register_model_graphql_type from users.graphql.mixins import OwnerMixin __all__ = ( @@ -19,9 +20,20 @@ __all__ = ( 'ObjectType', 'OrganizationalObjectType', 'PrimaryObjectType', + 'register_type', ) +def register_type(model, **kwargs): + """ + Drop-in replacement for `strawberry_django.type()` for model-bound NetBox GraphQL output types. Before delegating + to `strawberry_django.type()`, any plugin-registered output-type mixins for the given model are spliced into the + decorated class's bases. With no extensions registered this is an exact pass-through, leaving schema output + unchanged. See `register_model_graphql_type` for the registry-timing contract. + """ + return register_model_graphql_type(model, strawberry_django.type, 'graphql_type_extensions', **kwargs) + + # # Base types # @@ -159,7 +171,7 @@ class NetBoxObjectType( # Miscellaneous types # -@strawberry_django.type( +@register_type( ContentType, fields=['id', 'app_label', 'model'], pagination=True @@ -168,7 +180,7 @@ class ContentTypeType: pass -@strawberry_django.type( +@register_type( ObjectType_, fields=['id', 'app_label', 'model'], pagination=True diff --git a/netbox/netbox/graphql/utils.py b/netbox/netbox/graphql/utils.py new file mode 100644 index 000000000..a21eb3654 --- /dev/null +++ b/netbox/netbox/graphql/utils.py @@ -0,0 +1,126 @@ +import logging + +from netbox.registry import registry + +__all__ = ( + 'get_model_label', + 'register_model_graphql_type', + 'splice_extension_bases', +) + + +def get_model_label(model): + """ + Return the canonical `app_label.model_name` label used to key GraphQL extensions in the registry. Both the + registration side and the lookup side must derive labels through this helper so they always agree. + """ + return f'{model._meta.app_label}.{model._meta.model_name}' + + +def _own_names(klass): + """ + Return the set of field/attribute names a single class contributes directly: its annotations and its own + non-dunder attributes (such as resolver methods), excluding the `models` extension marker. + """ + names = set(getattr(klass, '__annotations__', {})) + names |= {name for name in vars(klass) if not name.startswith('__')} + names.discard('models') + return names + + +def _core_names(cls): + """ + Return every name `cls` resolves (its own body and everything it inherits). Extensions are spliced in *after* + these bases, so any name already present here is provided by the core type and an extension cannot override it. + """ + names = set() + for klass in cls.__mro__: + if klass is object: + continue + names |= _own_names(klass) + return names + + +def splice_extension_bases(cls, extensions): + """ + Return a class equivalent to `cls` but with the given plugin extension mixin classes spliced into its bases, + so that fields/filters they declare are picked up when the class is processed by Strawberry. + + If `extensions` is empty, `cls` is returned unchanged (an exact pass-through). Otherwise a new class is built + with the same name and namespace as `cls` — preserving its own annotations, fields, and methods — with the + extension classes appended to its bases. + + Precedence: extensions are appended *after* `cls`'s base classes in the MRO, so extensions are strictly + additive. Any name the core type already provides (its own fields or anything it inherits, including hooks such + as `get_queryset`) always wins; an extension declaring such a name is ignored. When two extensions declare the + same new name, the one whose plugin loaded first wins (it is registered earlier). Both cases are warned about. + """ + if not extensions: + return cls + + # Fetch the logger lazily rather than at module import. Importing this module during settings/app loading + # (e.g. via the plugin registration helpers) happens before Django configures logging; creating the logger + # then would get it disabled by a `disable_existing_loggers` LOGGING config. + logger = logging.getLogger('netbox.graphql') + + # Warn on field-name collisions so they can be diagnosed in deployments with many plugins. + core_names = _core_names(cls) + seen = {} + for extension in extensions: + for name in _own_names(extension): + if name in core_names: + logger.warning( + "GraphQL extension %s declares '%s', which core type %s already provides; the extension's " + "version is ignored (core takes precedence).", + extension, name, cls.__name__, + ) + elif name in seen: + logger.warning( + "GraphQL extensions %s and %s both define '%s' on %s; %s takes precedence because its " + "plugin is loaded first.", + seen[name], extension, name, cls.__name__, seen[name], + ) + else: + seen[name] = extension + + namespace = dict(cls.__dict__) + # Drop the descriptors that cannot (and need not) be copied to the rebuilt class; they are recreated by the + # metaclass call below. + namespace.pop('__dict__', None) + namespace.pop('__weakref__', None) + + bases = (*cls.__bases__, *extensions) + # Rebuild via the class's own metaclass rather than the built-in `type`, so a core type using a custom + # metaclass is preserved. Pre-decoration Strawberry/dataclass types use the plain `type` metaclass. + try: + return type(cls)(cls.__name__, bases, namespace) + except TypeError as exc: + raise TypeError( + f"Failed to splice GraphQL extension(s) {[e.__name__ for e in extensions]} into core type " + f"'{cls.__name__}': {exc}. A GraphQL extension should be a plain @strawberry.type mixin that only " + f"adds fields; inheriting from classes already in the core type's base list can produce an " + f"inconsistent MRO." + ) from exc + + +def register_model_graphql_type(model, delegate, store_key, **kwargs): + """ + Shared implementation behind `register_type` and `register_filter`. Returns a decorator that splices any + plugin extensions for `model` (from `store_key`) into the decorated class, then delegates to `delegate` + (`strawberry_django.type` / `filter_type`). + + The registry is read at decoration (import) time. This is safe because the schema is assembled lazily from the + URLconf, after every plugin's `ready()` has run; importing a core `graphql/types.py` during app init would read + the registry too early and silently drop later-registered extensions. + """ + label = get_model_label(model) + + def wrapper(cls): + # Record that this type/filter has been assembled, so a plugin that registers an extension after this + # point (e.g. because its ready() imported a core graphql module early) can be warned it is too late. + registry['plugins']['graphql_extensions_assembled'].add((store_key, label)) + extensions = registry['plugins'][store_key].get(label) + cls = splice_extension_bases(cls, extensions) + return delegate(model, **kwargs)(cls) + + return wrapper diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index 8b4629277..62cf47c16 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -21,6 +21,11 @@ registry['plugins'].update({ 'installed': [], 'graphql_schemas': [], 'jinja_filters': {}, + 'graphql_type_extensions': collections.defaultdict(list), + 'graphql_filter_extensions': collections.defaultdict(list), + # (store_key, label) pairs whose core type/filter has already been assembled, used to detect extensions + # registered too late to be spliced in. + 'graphql_extensions_assembled': set(), 'menus': [], 'menu_items': {}, 'preferences': {}, @@ -32,6 +37,8 @@ DEFAULT_RESOURCE_PATHS = { 'data_backends': 'data_backends.backends', 'graphql_schema': 'graphql.schema', 'jinja_filters': 'jinja_env.filters', + 'graphql_type_extensions': 'graphql.type_extensions', + 'graphql_filter_extensions': 'graphql.filter_extensions', 'menu': 'navigation.menu', 'menu_items': 'navigation.menu_items', 'template_extensions': 'template_content.template_extensions', @@ -81,6 +88,8 @@ class PluginConfig(AppConfig): data_backends = None graphql_schema = None jinja_filters = None + graphql_type_extensions = None + graphql_filter_extensions = None menu = None menu_items = None serializer_resolver = None @@ -151,6 +160,13 @@ class PluginConfig(AppConfig): if graphql_schema := self._load_resource('graphql_schema'): register_graphql_schema(graphql_schema) + # Register GraphQL type & filter extensions (if defined). These must be registered before the GraphQL + # schema is assembled (during ROOT_URLCONF loading), which occurs after all apps' ready() methods run. + if graphql_type_extensions := self._load_resource('graphql_type_extensions'): + register_graphql_type_extensions(graphql_type_extensions) + if graphql_filter_extensions := self._load_resource('graphql_filter_extensions'): + register_graphql_filter_extensions(graphql_filter_extensions) + # Register user preferences (if defined) if user_preferences := self._load_resource('user_preferences'): register_user_preferences(plugin_name, user_preferences) diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index 17b5ce79c..2bdecd637 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -1,8 +1,10 @@ import inspect import logging +from django.apps import apps from django.utils.translation import gettext_lazy as _ +from netbox.graphql.utils import get_model_label from netbox.registry import registry from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem @@ -11,7 +13,9 @@ from .templates import PluginTemplateExtension logger = logging.getLogger(__name__) __all__ = ( + 'register_graphql_filter_extensions', 'register_graphql_schema', + 'register_graphql_type_extensions', 'register_jinja_filters', 'register_menu', 'register_menu_items', @@ -102,6 +106,74 @@ def register_graphql_schema(graphql_schema): registry['plugins']['graphql_schemas'].extend(graphql_schema) +def _register_graphql_extensions(class_list, store): + """ + Collect a list of GraphQL output-type or filter mixin classes into the given registry store, bucketed by the + model labels declared on each class's `models` attribute. Each declared label is validated against the app + registry and normalized to the canonical `app_label.model_name` form (via `get_model_label`) so that the + stored key always matches the label `register_type`/`register_filter` look up. + """ + for extension in class_list: + if not inspect.isclass(extension): + raise TypeError( + _("GraphQL extension {extension} was passed as an instance!").format(extension=extension) + ) + models = getattr(extension, 'models', None) + if not models: + raise TypeError( + _("GraphQL extension {extension} must declare a non-empty 'models' attribute.").format( + extension=extension + ) + ) + # Must be @strawberry.type-decorated for its fields to be collected. Check the class's own __dict__ (not + # hasattr) so an undecorated subclass of a @strawberry.type base is still rejected. `__strawberry_definition__` + # is a Strawberry internal (verified against strawberry-graphql 0.321.0); revisit on dependency upgrades. + if '__strawberry_definition__' not in vars(extension): + raise TypeError( + _("GraphQL extension {extension} must be decorated with @strawberry.type.").format( + extension=extension + ) + ) + for label in models: + # Resolve the model to validate the label and derive its canonical key; a bad label would otherwise + # register into a bucket that is never looked up, silently dropping the extension. + try: + model = apps.get_model(label) + except (LookupError, ValueError): + raise TypeError( + _("GraphQL extension {extension} targets unknown model '{label}'.").format( + extension=extension, label=label + ) + ) + canonical_label = get_model_label(model) + # If the core type/filter was already assembled (a plugin imported a core graphql module during + # ready()), this extension is too late to be spliced in and will not appear in the schema. Fetch the + # logger lazily (not the module-level one) so it isn't disabled by a `disable_existing_loggers` config. + if (store, canonical_label) in registry['plugins']['graphql_extensions_assembled']: + logging.getLogger('netbox.graphql').warning( + "GraphQL extension %s for '%s' was registered after the core type was assembled and will be " + "ignored. Avoid importing core GraphQL modules from a plugin's ready().", + extension, canonical_label, + ) + registry['plugins'][store][canonical_label].append(extension) + + +def register_graphql_type_extensions(class_list): + """ + Register a list of GraphQL output-type mixin classes. Each class must be decorated with @strawberry.type and + declare a `models` attribute listing the `app_label.model` labels of the core types it extends. + """ + _register_graphql_extensions(class_list, 'graphql_type_extensions') + + +def register_graphql_filter_extensions(class_list): + """ + Register a list of GraphQL filter mixin classes. Each class must be decorated with @strawberry.type and declare + a `models` attribute listing the `app_label.model` labels of the core filters it extends. + """ + _register_graphql_extensions(class_list, 'graphql_filter_extensions') + + def register_user_preferences(plugin_name, preferences): """ Register a list of user preferences defined by a plugin. diff --git a/netbox/netbox/tests/dummy_plugin/graphql.py b/netbox/netbox/tests/dummy_plugin/graphql.py index 7f7d606fd..ea9f66bf2 100644 --- a/netbox/netbox/tests/dummy_plugin/graphql.py +++ b/netbox/netbox/tests/dummy_plugin/graphql.py @@ -1,6 +1,7 @@ import strawberry import strawberry_django +from django.db.models import Q from . import models @@ -22,3 +23,35 @@ class DummyQuery: schema = [ DummyQuery, ] + + +# +# Extensions to core GraphQL types & filters (see netbox.graphql.types.register_type / +# netbox.graphql.filters.register_filter). These exercise the plugin extension point. +# + +@strawberry.type +class SiteTypeExtension: + models = ['dcim.site'] + + @strawberry_django.field + def dummy_plugin_field(self) -> str: + return 'dummy-plugin-value' + + +@strawberry.type +class SiteFilterExtension: + models = ['dcim.site'] + + @strawberry_django.filter_field() + def dummy_plugin_filter(self, value: str, prefix) -> Q: + return Q(**{f'{prefix}name': value}) + + +type_extensions = [ + SiteTypeExtension, +] + +filter_extensions = [ + SiteFilterExtension, +] diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 95f607ff0..3988f47f5 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -1,7 +1,9 @@ import json import re +from unittest import skipIf import strawberry +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db import connection from django.test import override_settings @@ -133,6 +135,26 @@ class GraphQLAPITestCase(APITestCase): ) Site.objects.bulk_create(sites) + @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") + @override_settings(LOGIN_REQUIRED=True) + def test_graphql_plugin_extensions_execute(self): + """ + A plugin-provided filter extension and field extension execute end-to-end against a live query, + exercising the custom filter method's prefix plumbing and the type extension's resolver. + """ + self.add_permissions('dcim.view_site') + url = reverse('graphql') + + query = '{ site_list(filters: {dummy_plugin_filter: "Site 1"}) { name dummy_plugin_field } }' + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + sites = data['data']['site_list'] + self.assertEqual(len(sites), 1) + self.assertEqual(sites[0]['name'], 'Site 1') + self.assertEqual(sites[0]['dummy_plugin_field'], 'dummy-plugin-value') + @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): """ @@ -662,3 +684,130 @@ class JSONStringLookupTestCase(TestCase): 'starts_with', 'i_starts_with', 'ends_with', 'i_ends_with', 'in_', 'isnull', 'regex', 'i_regex'): self.assertIn(expected, field_names, f"{expected!r} must be present on JSONStringLookup") + + +class SpliceExtensionBasesTestCase(TestCase): + """Verify splice_extension_bases() behavior: pass-through, splicing, and collision warnings.""" + + @staticmethod + def _make_core(): + @strawberry.type + class CoreBase: + description: str # inherited (non-protected) field + + @classmethod + def get_queryset(cls, queryset, info, **kwargs): + return queryset + + @strawberry.type + class CoreType(CoreBase): + name: str # defined directly in the core type's own body + + return CoreType + + def test_no_extensions_is_passthrough(self): + from netbox.graphql.utils import splice_extension_bases + CoreType = self._make_core() + self.assertIs(splice_extension_bases(CoreType, []), CoreType) + self.assertIs(splice_extension_bases(CoreType, None), CoreType) + + def test_extension_spliced_into_bases(self): + from netbox.graphql.utils import splice_extension_bases + + @strawberry.type + class Extension: + models = ['dcim.device'] + extra: str + + CoreType = self._make_core() + result = splice_extension_bases(CoreType, [Extension]) + self.assertIsNot(result, CoreType) + self.assertEqual(result.__name__, CoreType.__name__) + self.assertIn(Extension, result.__mro__) + # The extension is appended *after* the core bases in the MRO (additive, core wins collisions) + self.assertGreater(result.__mro__.index(Extension), result.__mro__.index(CoreType.__bases__[0])) + + def test_warns_when_extension_collides_with_core_own_field(self): + # A name the core type defines directly always wins; the extension's version is ignored (and warned). + from netbox.graphql.utils import splice_extension_bases + + @strawberry.type + class Extension: + models = ['dcim.device'] + name: str # collides with CoreType.name (own body) + + CoreType = self._make_core() + with self.assertLogs('netbox.graphql', level='WARNING') as cm: + splice_extension_bases(CoreType, [Extension]) + self.assertTrue(any("already provides" in msg and "core takes precedence" in msg for msg in cm.output)) + + def test_warns_when_extension_collides_with_inherited_field(self): + # A name the core type inherits also wins over the extension (extensions are strictly additive). + from netbox.graphql.utils import splice_extension_bases + + @strawberry.type + class Extension: + models = ['dcim.device'] + description: str # collides with CoreBase.description (inherited) + + CoreType = self._make_core() + with self.assertLogs('netbox.graphql', level='WARNING') as cm: + splice_extension_bases(CoreType, [Extension]) + self.assertTrue(any("already provides" in msg for msg in cm.output)) + + def test_core_hook_wins_over_extension(self): + # An extension declaring get_queryset is ignored; the core permission-enforcing hook is preserved by + # ordering (extensions are appended after the core bases). + from netbox.graphql.utils import splice_extension_bases + + @strawberry.type + class Extension: + models = ['dcim.device'] + + @classmethod + def get_queryset(cls, queryset, info, **kwargs): + return 'EXTENSION_WON' + + CoreType = self._make_core() + with self.assertLogs('netbox.graphql', level='WARNING') as cm: + result = splice_extension_bases(CoreType, [Extension]) + self.assertTrue(any("already provides" in msg and "get_queryset" in msg for msg in cm.output)) + # Core's get_queryset (identity) is retained, not the extension's override + self.assertEqual(result.get_queryset('CORE_QS', None), 'CORE_QS') + + def test_mro_conflict_raises_clear_error(self): + from netbox.graphql.utils import splice_extension_bases + + class A: + pass + + class B: + pass + + class Core(A, B): + name = 'core' + + class Extension(B, A): # reversed base order -> inconsistent MRO when spliced + models = ['dcim.device'] + + with self.assertRaises(TypeError) as ctx: + splice_extension_bases(Core, [Extension]) + self.assertIn('Failed to splice', str(ctx.exception)) + + def test_warns_on_collision_between_extensions(self): + from netbox.graphql.utils import splice_extension_bases + + @strawberry.type + class ExtensionA: + models = ['dcim.device'] + widgets: str + + @strawberry.type + class ExtensionB: + models = ['dcim.device'] + widgets: str + + CoreType = self._make_core() + with self.assertLogs('netbox.graphql', level='WARNING') as cm: + splice_extension_bases(CoreType, [ExtensionA, ExtensionB]) + self.assertTrue(any("both define" in msg and "loaded first" in msg for msg in cm.output)) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 6e89eab4a..f485b213a 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -1,3 +1,4 @@ +import re from unittest import skipIf from django.conf import settings @@ -216,6 +217,26 @@ class PluginTestCase(TestCase): self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertTrue(issubclass(Query, DummyQuery)) + def test_graphql_type_extensions(self): + """ + Validate that plugin GraphQL type & filter extensions are registered and spliced into the built schema. + """ + from netbox.graphql.schema import schema + from netbox.tests.dummy_plugin.graphql import SiteFilterExtension, SiteTypeExtension + + # Extensions are registered against the targeted core model + self.assertIn(SiteTypeExtension, registry['plugins']['graphql_type_extensions']['dcim.site']) + self.assertIn(SiteFilterExtension, registry['plugins']['graphql_filter_extensions']['dcim.site']) + + # The injected field and filter appear in the assembled schema + schema_str = schema.as_str() + site_type = re.search(r'\ntype SiteType \{.*?\n\}', schema_str, re.DOTALL) + self.assertIsNotNone(site_type, "SiteType not found in GraphQL schema") + self.assertIn('dummy_plugin_field', site_type.group(0)) + site_filter = re.search(r'\ninput SiteFilter \{.*?\n\}', schema_str, re.DOTALL) + self.assertIsNotNone(site_filter, "SiteFilter not found in GraphQL schema") + self.assertIn('dummy_plugin_filter', site_filter.group(0)) + @override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}}) def test_get_plugin_config(self): """ @@ -359,3 +380,93 @@ class PluginNavigationTestCase(TestCase): self.assertIsNot(item1.permissions, item2.permissions) self.assertEqual(item1.permissions, ['explicit_permission']) self.assertEqual(item2.permissions, ['different_permission']) + + +class RegisterGraphQLExtensionsTestCase(TestCase): + """Validate registration-time checks for GraphQL type/filter extensions.""" + + def test_rejects_extension_without_models(self): + import strawberry + + from netbox.plugins.registration import register_graphql_type_extensions + + @strawberry.type + class NoModels: + pass + + with self.assertRaises(TypeError): + register_graphql_type_extensions([NoModels]) + + def test_rejects_undecorated_extension(self): + # A plain class (no @strawberry.type) must be rejected... + from netbox.plugins.registration import register_graphql_type_extensions + + class Undecorated: + models = ['dcim.device'] + + with self.assertRaises(TypeError): + register_graphql_type_extensions([Undecorated]) + + def test_rejects_undecorated_subclass_of_strawberry_type(self): + # ...as must a subclass that only inherits __strawberry_definition__ without its own decoration. + import strawberry + + from netbox.plugins.registration import register_graphql_type_extensions + + @strawberry.type + class Base: + pass + + class Child(Base): + models = ['dcim.device'] + + with self.assertRaises(TypeError): + register_graphql_type_extensions([Child]) + + def test_rejects_unknown_model_label(self): + import strawberry + + from netbox.plugins.registration import register_graphql_type_extensions + + @strawberry.type + class BadTarget: + models = ['dcim.notamodel'] + + with self.assertRaises(TypeError): + register_graphql_type_extensions([BadTarget]) + + def test_filter_extension_requires_strawberry_type(self): + # The filter path enforces the same @strawberry.type requirement as the type path. + from netbox.plugins.registration import register_graphql_filter_extensions + + class UndecoratedFilter: + models = ['dcim.device'] + + with self.assertRaises(TypeError): + register_graphql_filter_extensions([UndecoratedFilter]) + + def test_warns_when_registered_after_assembly(self): + # An extension registered after its core type was already assembled is warned and will be dropped. + import strawberry + + from netbox.plugins.registration import register_graphql_type_extensions + + @strawberry.type + class LateExt: + models = ['dcim.cable'] + late_field: str + + store, label = 'graphql_type_extensions', 'dcim.cable' + assembled = registry['plugins']['graphql_extensions_assembled'] + was_present = (store, label) in assembled + assembled.add((store, label)) + # Restore global registry state regardless of outcome so other tests are unaffected. + self.addCleanup(lambda: registry['plugins'][store].__setitem__( + label, [e for e in registry['plugins'][store][label] if e is not LateExt] + )) + if not was_present: + self.addCleanup(assembled.discard, (store, label)) + + with self.assertLogs('netbox.graphql', level='WARNING') as cm: + register_graphql_type_extensions([LateExt]) + self.assertTrue(any('after the core type was assembled' in msg for msg in cm.output)) diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index 0a570c980..c7f68375d 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -11,6 +11,7 @@ from netbox.graphql.filters import ( NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter, + register_filter, ) from tenancy import models @@ -58,7 +59,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.Tenant, lookups=True) +@register_filter(models.Tenant, lookups=True) class TenantFilter(ContactFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -137,7 +138,7 @@ class TenantFilter(ContactFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.TenantGroup, lookups=True) +@register_filter(models.TenantGroup, lookups=True) class TenantGroupFilter(OrganizationalModelFilter): parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -151,7 +152,7 @@ class TenantGroupFilter(OrganizationalModelFilter): ) -@strawberry_django.filter_type(models.Contact, lookups=True) +@register_filter(models.Contact, lookups=True) class ContactFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() title: StrFilterLookup | None = strawberry_django.filter_field() @@ -167,19 +168,19 @@ class ContactFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.ContactRole, lookups=True) +@register_filter(models.ContactRole, lookups=True) class ContactRoleFilter(OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.ContactGroup, lookups=True) +@register_filter(models.ContactGroup, lookups=True) class ContactGroupFilter(NestedGroupModelFilter): parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter_type(models.ContactAssignment, lookups=True) +@register_filter(models.ContactAssignment, lookups=True) class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index aa9c33234..312ce465e 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,10 +1,15 @@ from typing import TYPE_CHECKING, Annotated import strawberry -import strawberry_django from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, NestedLtreeGroupObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import ( + BaseObjectType, + NestedLtreeGroupObjectType, + OrganizationalObjectType, + PrimaryObjectType, + register_type, +) from tenancy import models from .filters import * @@ -52,7 +57,7 @@ __all__ = ( # Tenants # -@strawberry_django.type( +@register_type( models.Tenant, fields='__all__', filters=TenantFilter, @@ -86,7 +91,7 @@ class TenantType(ContactsMixin, PrimaryObjectType): l2vpns: list[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.TenantGroup, exclude=['path', 'sort_path'], filters=TenantGroupFilter, @@ -103,7 +108,7 @@ class TenantGroupType(NestedLtreeGroupObjectType): # Contacts # -@strawberry_django.type( +@register_type( models.Contact, fields='__all__', filters=ContactFilter, @@ -113,7 +118,7 @@ class ContactType(ContactAssignmentsMixin, PrimaryObjectType): groups: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] -@strawberry_django.type( +@register_type( models.ContactRole, fields='__all__', filters=ContactRoleFilter, @@ -123,7 +128,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): pass -@strawberry_django.type( +@register_type( models.ContactGroup, exclude=['path', 'sort_path'], filters=ContactGroupFilter, @@ -136,7 +141,7 @@ class ContactGroupType(NestedLtreeGroupObjectType): children: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] -@strawberry_django.type( +@register_type( models.ContactAssignment, fields='__all__', filters=ContactAssignmentFilter, diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index 6ff40597c..bcec0456c 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -4,7 +4,7 @@ import strawberry import strawberry_django from strawberry_django import DatetimeFilterLookup, FilterLookup, StrFilterLookup -from netbox.graphql.filters import BaseModelFilter +from netbox.graphql.filters import BaseModelFilter, register_filter from users import models __all__ = ( @@ -15,13 +15,13 @@ __all__ = ( ) -@strawberry_django.filter_type(models.Group, lookups=True) +@register_filter(models.Group, lookups=True) class GroupFilter(BaseModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.User, lookups=True) +@register_filter(models.User, lookups=True) class UserFilter(BaseModelFilter): username: StrFilterLookup | None = strawberry_django.filter_field() first_name: StrFilterLookup | None = strawberry_django.filter_field() @@ -34,7 +34,7 @@ class UserFilter(BaseModelFilter): groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Owner, lookups=True) +@register_filter(models.Owner, lookups=True) class OwnerFilter(BaseModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -47,7 +47,7 @@ class OwnerFilter(BaseModelFilter): users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.OwnerGroup, lookups=True) +@register_filter(models.OwnerGroup, lookups=True) class OwnerGroupFilter(BaseModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 57d4839a2..0edbdfec7 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -1,7 +1,6 @@ -import strawberry_django -from netbox.graphql.types import BaseObjectType +from netbox.graphql.types import BaseObjectType, register_type from users.models import Group, Owner, OwnerGroup, User from .filters import * @@ -14,7 +13,7 @@ __all__ = ( ) -@strawberry_django.type( +@register_type( Group, fields=['id', 'name'], filters=GroupFilter, @@ -24,7 +23,7 @@ class GroupType(BaseObjectType): pass -@strawberry_django.type( +@register_type( User, fields=[ 'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups', @@ -36,7 +35,7 @@ class UserType(BaseObjectType): groups: list[GroupType] -@strawberry_django.type( +@register_type( OwnerGroup, fields=['id', 'name', 'description'], filters=OwnerGroupFilter, @@ -46,7 +45,7 @@ class OwnerGroupType(BaseObjectType): pass -@strawberry_django.type( +@register_type( Owner, fields=['id', 'group', 'name', 'description', 'user_groups', 'users'], filters=OwnerFilter, diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index c3f62fa1c..4f13e7037 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -8,7 +8,7 @@ from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLo from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin from extras.graphql.filter_mixins import ConfigContextFilterMixin from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin -from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter +from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, register_filter from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin from virtualization import models from virtualization.graphql.filter_mixins import VMComponentFilterMixin @@ -38,7 +38,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.Cluster, lookups=True) +@register_filter(models.Cluster, lookups=True) class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( @@ -57,19 +57,19 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P ) -@strawberry_django.filter_type(models.ClusterGroup, lookups=True) +@register_filter(models.ClusterGroup, lookups=True) 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) +@register_filter(models.ClusterType, lookups=True) class ClusterTypeFilter(OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.VirtualMachineType, lookups=True) +@register_filter(models.VirtualMachineType, lookups=True) class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter): default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -87,7 +87,7 @@ class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter): virtual_machine_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.VirtualMachine, lookups=True) +@register_filter(models.VirtualMachine, lookups=True) class VirtualMachineFilter( ContactFilterMixin, ImageAttachmentFilterMixin, @@ -157,7 +157,7 @@ class VirtualMachineFilter( ) -@strawberry_django.filter_type(models.VMInterface, lookups=True) +@register_filter(models.VMInterface, lookups=True) class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBoxModelFilter): ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -182,7 +182,7 @@ class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBox ) -@strawberry_django.filter_type(models.VirtualDisk, lookups=True) +@register_filter(models.VirtualDisk, lookups=True) class VirtualDiskFilter(VMComponentFilterMixin, NetBoxModelFilter): size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 81ef685a1..4df3655aa 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -6,7 +6,7 @@ import strawberry_django from extras.graphql.mixins import ConfigContextMixin, ContactsMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType, register_type from users.graphql.mixins import OwnerMixin from virtualization import models @@ -46,7 +46,7 @@ class ComponentType(OwnerMixin, NetBoxObjectType): virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] -@strawberry_django.type( +@register_type( models.Cluster, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=ClusterFilter, @@ -70,7 +70,7 @@ class ClusterType(ContactsMixin, VLANGroupsMixin, PrimaryObjectType): return self.scope -@strawberry_django.type( +@register_type( models.ClusterGroup, fields='__all__', filters=ClusterGroupFilter, @@ -81,7 +81,7 @@ class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType) clusters: list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] -@strawberry_django.type( +@register_type( models.ClusterType, fields='__all__', filters=ClusterTypeFilter, @@ -92,7 +92,7 @@ class ClusterTypeType(OrganizationalObjectType): clusters: list[ClusterType] -@strawberry_django.type( +@register_type( models.VirtualMachineType, fields='__all__', filters=VirtualMachineTypeFilter, @@ -105,7 +105,7 @@ class VirtualMachineTypeType(PrimaryObjectType): instances: list[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]] -@strawberry_django.type( +@register_type( models.VirtualMachine, fields='__all__', filters=VirtualMachineFilter, @@ -131,7 +131,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType): virtualdisks: list[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]] -@strawberry_django.type( +@register_type( models.VMInterface, fields='__all__', filters=VMInterfaceFilter, @@ -154,7 +154,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.VirtualDisk, fields='__all__', filters=VirtualDiskFilter, diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index 9b068859c..3cb5527ad 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -11,6 +11,7 @@ from netbox.graphql.filters import ( NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, + register_filter, ) from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin from vpn import models @@ -36,12 +37,12 @@ __all__ = ( ) -@strawberry_django.filter_type(models.TunnelGroup, lookups=True) +@register_filter(models.TunnelGroup, lookups=True) class TunnelGroupFilter(OrganizationalModelFilter): pass -@strawberry_django.filter_type(models.TunnelTermination, lookups=True) +@register_filter(models.TunnelTermination, lookups=True) 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() @@ -61,7 +62,7 @@ class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo outside_ip_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Tunnel, lookups=True) +@register_filter(models.Tunnel, lookups=True) class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( @@ -87,7 +88,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.IKEProposal, lookups=True) +@register_filter(models.IKEProposal, lookups=True) class IKEProposalFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() authentication_method: ( @@ -116,7 +117,7 @@ class IKEProposalFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.IKEPolicy, lookups=True) +@register_filter(models.IKEPolicy, lookups=True) class IKEPolicyFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( @@ -131,7 +132,7 @@ class IKEPolicyFilter(PrimaryModelFilter): preshared_key: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.IPSecProposal, lookups=True) +@register_filter(models.IPSecProposal, lookups=True) class IPSecProposalFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() encryption_algorithm: ( @@ -157,7 +158,7 @@ class IPSecProposalFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.IPSecPolicy, lookups=True) +@register_filter(models.IPSecPolicy, lookups=True) class IPSecPolicyFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( @@ -168,7 +169,7 @@ class IPSecPolicyFilter(PrimaryModelFilter): ) -@strawberry_django.filter_type(models.IPSecProfile, lookups=True) +@register_filter(models.IPSecProfile, lookups=True) class IPSecProfileFilter(PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( @@ -184,7 +185,7 @@ class IPSecProfileFilter(PrimaryModelFilter): ipsec_policy_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.L2VPN, lookups=True) +@register_filter(models.L2VPN, lookups=True) class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -208,7 +209,7 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter): ) -@strawberry_django.filter_type(models.L2VPNTermination, lookups=True) +@register_filter(models.L2VPNTermination, lookups=True) class L2VPNTerminationFilter(NetBoxModelFilter): l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() l2vpn_id: ID | None = strawberry_django.filter_field() diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index 40f125e83..049860a3e 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -4,7 +4,13 @@ import strawberry import strawberry_django from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin -from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import ( + NetBoxObjectType, + ObjectType, + OrganizationalObjectType, + PrimaryObjectType, + register_type, +) from vpn import models from .filters import * @@ -30,7 +36,7 @@ __all__ = ( ) -@strawberry_django.type( +@register_type( models.TunnelGroup, fields='__all__', filters=TunnelGroupFilter, @@ -41,7 +47,7 @@ class TunnelGroupType(ContactsMixin, OrganizationalObjectType): tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.TunnelTermination, fields='__all__', filters=TunnelTerminationFilter, @@ -53,7 +59,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): outside_ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Tunnel, fields='__all__', filters=TunnelFilter, @@ -67,7 +73,7 @@ class TunnelType(ContactsMixin, PrimaryObjectType): terminations: list[Annotated["TunnelTerminationType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.IKEProposal, fields='__all__', filters=IKEProposalFilter, @@ -77,7 +83,7 @@ class IKEProposalType(PrimaryObjectType): ike_policies: list[Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.IKEPolicy, fields='__all__', filters=IKEPolicyFilter, @@ -88,7 +94,7 @@ class IKEPolicyType(PrimaryObjectType): ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.IPSecProposal, fields='__all__', filters=IPSecProposalFilter, @@ -98,7 +104,7 @@ class IPSecProposalType(PrimaryObjectType): ipsec_policies: list[Annotated["IPSecPolicyType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.IPSecPolicy, fields='__all__', filters=IPSecPolicyFilter, @@ -109,7 +115,7 @@ class IPSecPolicyType(PrimaryObjectType): ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.IPSecProfile, fields='__all__', filters=IPSecProfileFilter, @@ -122,7 +128,7 @@ class IPSecProfileType(PrimaryObjectType): tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]] -@strawberry_django.type( +@register_type( models.L2VPN, fields='__all__', filters=L2VPNFilter, @@ -136,7 +142,7 @@ class L2VPNType(ContactsMixin, PrimaryObjectType): import_targets: list[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] -@strawberry_django.type( +@register_type( models.L2VPNTermination, exclude=['assigned_object_type', 'assigned_object_id'], filters=L2VPNTerminationFilter, diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index 3bcef7725..24450f4fe 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -7,7 +7,7 @@ from strawberry_django import BaseFilterLookup, StrFilterLookup from dcim.graphql.filter_mixins import ScopedFilterMixin from netbox.graphql.filter_mixins import DistanceFilterMixin -from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter +from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter, register_filter from tenancy.graphql.filter_mixins import TenancyFilterMixin from wireless import models @@ -26,12 +26,12 @@ __all__ = ( ) -@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True) +@register_filter(models.WirelessLANGroup, lookups=True) class WirelessLANGroupFilter(NestedGroupModelFilter): pass -@strawberry_django.filter_type(models.WirelessLAN, lookups=True) +@register_filter(models.WirelessLAN, lookups=True) class WirelessLANFilter( WirelessAuthenticationFilterMixin, ScopedFilterMixin, @@ -50,7 +50,7 @@ class WirelessLANFilter( vlan_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.WirelessLink, lookups=True) +@register_filter(models.WirelessLink, lookups=True) class WirelessLinkFilter( WirelessAuthenticationFilterMixin, DistanceFilterMixin, diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 060175ff0..cadcbf70f 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated import strawberry import strawberry_django -from netbox.graphql.types import NestedLtreeGroupObjectType, PrimaryObjectType +from netbox.graphql.types import NestedLtreeGroupObjectType, PrimaryObjectType, register_type from wireless import models from .filters import * @@ -20,7 +20,7 @@ __all__ = ( ) -@strawberry_django.type( +@register_type( models.WirelessLANGroup, exclude=['path', 'sort_path'], filters=WirelessLANGroupFilter, @@ -33,7 +33,7 @@ class WirelessLANGroupType(NestedLtreeGroupObjectType): children: list[Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')]] -@strawberry_django.type( +@register_type( models.WirelessLAN, exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], filters=WirelessLANFilter, @@ -57,7 +57,7 @@ class WirelessLANType(PrimaryObjectType): return self.scope -@strawberry_django.type( +@register_type( models.WirelessLink, fields='__all__', filters=WirelessLinkFilter,