Răsfoiți Sursa

Closes #22721: Enable plugins to extend core GraphQL API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jeremy Stretch 1 lună în urmă
părinte
comite
a24fbb06ce

+ 90 - 2
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.

+ 2 - 0
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.

+ 17 - 12
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()

+ 12 - 12
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,

+ 5 - 5
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()

+ 5 - 6
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

+ 50 - 49
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()

+ 50 - 49
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,

+ 18 - 18
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()

+ 18 - 19
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,

+ 19 - 18
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()

+ 25 - 19
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,

+ 12 - 0
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()

+ 14 - 2
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

+ 126 - 0
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

+ 16 - 0
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)

+ 72 - 0
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.

+ 33 - 0
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,
+]

+ 149 - 0
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))

+ 111 - 0
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))

+ 7 - 6
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()

+ 13 - 8
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,

+ 5 - 5
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()

+ 5 - 6
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,

+ 8 - 8
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()

+ 8 - 8
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,

+ 11 - 10
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()

+ 17 - 11
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,

+ 4 - 4
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,

+ 4 - 4
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,