2
0
Martin Hauser 1 долоо хоног өмнө
parent
commit
f4fdd60e8d

+ 17 - 9
docs/plugins/development/graphql-api.md

@@ -37,24 +37,24 @@ schema = [
 
 ## Extending Core Types & Filters
 
-!!! info "This feature was introduced in NetBox v4.6."
+!!! info "This feature was introduced in NetBox v4.7."
 
 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.
+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. Declare `models` as an unannotated class attribute or a `ClassVar`. An annotated `models` would be collected as a GraphQL field and is rejected. Output-type extensions are collected from `graphql_extensions.type_extensions` and filter extensions from `graphql_extensions.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.
+By default, NetBox imports `type_extensions` and `filter_extensions` from a `graphql_extensions.py` module beside the plugin's `graphql.py`. The `PluginConfig` attributes may override each with a dotted path to a list under any attribute name.
 
 !!! 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.
+    Extension modules are imported while plugins initialize, before NetBox's core GraphQL types are assembled. They must not import core GraphQL modules (e.g. `dcim.graphql.types`) at module level. A premature import assembles the affected core types early, and any extension registered afterwards for one of them fails at startup. Reference core types only through `strawberry.lazy()` string annotations. Plugin schema modules (`graphql.py`) are loaded later, during schema assembly, and may import core GraphQL types freely.
 
 ### 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
+# graphql_extensions.py
+from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
@@ -62,6 +62,14 @@ import strawberry_django
 from utilities.querysets import RestrictedPrefetch
 from my_plugin.models import Widget
 
+if TYPE_CHECKING:
+    from dcim.graphql.types import DeviceType
+
+
+@strawberry_django.type(Widget, fields='__all__')
+class WidgetType:
+    device: Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
+
 
 @strawberry.type
 class DeviceTypeExtension:
@@ -72,7 +80,7 @@ class DeviceTypeExtension:
             'widgets', info.context.request.user, 'view', queryset=Widget.objects.all()
         ),
     )
-    def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql.types')]]:
+    def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql_extensions')]]:
         return self.widgets.all()
 
 
@@ -89,7 +97,7 @@ type_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)
+# graphql_extensions.py
 import strawberry
 import strawberry_django
 from django.db.models import Q
@@ -121,7 +129,7 @@ query {
 ```
 
 !!! 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.
+    Extensions are strictly additive. Every name an extension contributes must be new, not only its GraphQL fields and resolvers but also its helper methods and class attributes. A name the core type already provides, or that two extensions both declare, causes NetBox to fail at startup with an error naming the extension classes. This is deliberate, because Python resolves attribute lookups through the composed MRO, so a shared helper or constant name would let one plugin silently redirect another plugin's resolvers. Two extensions may inherit the same name from one shared helper base, which is not a conflict. Explicit GraphQL aliases are checked as well. Registering an extension after its target GraphQL type has been assembled also raises an error, as does an extension targeting a model that never assembles a GraphQL type or filter. Extensions are plain mixin types and may not implement GraphQL interfaces or inherit from core GraphQL classes.
 
 ## GraphQL Objects
 

+ 2 - 2
docs/plugins/development/index.md

@@ -124,8 +124,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`)              |
+| `graphql_type_extensions` | The dotted path to the list of GraphQL output-type extension classes, if any (default: `graphql_extensions.type_extensions`)   |
+| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql_extensions.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.

+ 1 - 0
netbox/netbox/configuration_testing.py

@@ -18,6 +18,7 @@ DATABASES = {
 
 PLUGINS = [
     'netbox.tests.dummy_plugin',
+    'netbox.tests.dummy_plugin_b',
 ]
 
 RQ = {

+ 13 - 0
netbox/netbox/graphql/apps.py

@@ -0,0 +1,13 @@
+from django.apps import AppConfig
+
+
+class GraphQLConfig(AppConfig):
+    name = 'netbox.graphql'
+    label = 'netbox_graphql'
+
+    def ready(self):
+        # Runs after every plugin's ready(), so schema errors fail django.setup() instead of the first request.
+        from netbox.graphql import schema  # noqa: F401
+        from netbox.graphql.utils import validate_extension_targets
+
+        validate_extension_targets()

+ 5 - 0
netbox/netbox/graphql/schema.py

@@ -11,6 +11,7 @@ from core.graphql.schema import CoreQuery
 from dcim.graphql.schema import DCIMQuery
 from extras.graphql.schema import ExtrasQuery
 from ipam.graphql.schema import IPAMQuery
+from netbox.plugins import _load_plugin_graphql_schemas
 from netbox.registry import registry
 from tenancy.graphql.schema import TenancyQuery
 from users.graphql.schema import UsersQuery
@@ -23,6 +24,10 @@ from .scalars import BigInt, BigIntScalar
 SchemaExtensionFactory = type[SchemaExtension] | Callable[[], SchemaExtension]
 
 
+# Must run before Query is defined, since its bases consume the registered plugin schemas.
+_load_plugin_graphql_schemas()
+
+
 @strawberry.type
 class Query(
     UsersQuery,

+ 145 - 69
netbox/netbox/graphql/utils.py

@@ -1,11 +1,10 @@
-import logging
+from django.core.exceptions import ImproperlyConfigured
 
 from netbox.registry import registry
 
 __all__ = (
     'get_model_label',
     'register_model_graphql_type',
-    'splice_extension_bases',
 )
 
 
@@ -18,22 +17,43 @@ def get_model_label(model):
 
 
 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('__')}
+    """Concrete attributes only, since PEP 649 keeps annotations out of the class dict from Python 3.14 on."""
+    names = {name for name in vars(klass) if not name.startswith('__')}
     names.discard('models')
     return names
 
 
+def _field_names(klass):
+    """Python names from the class's own completed Strawberry definition, including fields it inherited."""
+    definition = vars(klass).get('__strawberry_definition__')
+    if definition is None:
+        return set()
+    return {field.python_name for field in definition.fields if field.python_name is not None} - {'models'}
+
+
+def _all_names(klass):
+    """Strawberry fields plus concrete attributes anywhere in the MRO, so no raw annotation is ever read."""
+    names = _field_names(klass)
+    for base in klass.__mro__:
+        if base is not object:
+            names |= _own_names(base)
+    return names
+
+
+def _name_owner(klass, name):
+    """Class in the MRO declaring `name` as a real attribute, or None when it is annotation-only."""
+    for base in klass.__mro__:
+        if name in vars(base):
+            return base
+    return None
+
+
 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()
+    names = _field_names(cls)
     for klass in cls.__mro__:
         if klass is object:
             continue
@@ -41,86 +61,142 @@ def _core_names(cls):
     return names
 
 
-def splice_extension_bases(cls, extensions):
+def _class_path(cls):
+    """Module-qualified identity for startup errors, since bare class names collide across plugins."""
+    return f'{cls.__module__}.{cls.__qualname__}'
+
+
+def _compose(cls, extensions):
+    """
+    Build a subclass of `cls` with the extension mixins appended to its bases. `cls` is already decorated, so
+    the composed class needs no namespace of its own: fields and methods are inherited, zero-argument super()
+    in core methods keeps working, and core attributes win every MRO lookup.
     """
-    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.
+    namespace = {'__module__': cls.__module__, '__qualname__': cls.__qualname__, '__doc__': cls.__doc__}
+    try:
+        return type(cls)(cls.__name__, (cls, *extensions), namespace)
+    except TypeError as exc:
+        raise ImproperlyConfigured(
+            f"Failed to compose GraphQL extension(s) {[_class_path(e) 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. The extensions declare conflicting base class orders."
+        ) from exc
 
-    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.
+def splice_extension_bases(cls, extensions):
+    """
+    Return `cls` composed with the given extension mixins, or `cls` unchanged when there are none. Extensions are
+    strictly additive. An extension sharing ancestry with the core type, declaring a Python name the core type
+    already resolves, or declaring a name another extension claims raises ImproperlyConfigured.
     """
     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 = {}
+    core_bases = set(cls.__mro__)
+    claimed = {}
     for extension in extensions:
-        for name in _own_names(extension):
+        # A shared ancestor lets C3 interleave extension bases ahead of core hooks such as get_queryset().
+        if shared := [base for base in extension.__mro__ if base is not object and base in core_bases]:
+            raise ImproperlyConfigured(
+                f"GraphQL extension {_class_path(extension)} shares ancestry with core type "
+                f"'{cls.__name__}' ({_class_path(shared[0])}). An extension must be an independent "
+                f"mixin that does not inherit from core GraphQL classes."
+            )
+        for name in _all_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__,
+                raise ImproperlyConfigured(
+                    f"GraphQL extension {_class_path(extension)} declares '{name}', which core type "
+                    f"'{cls.__name__}' already provides."
                 )
-            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],
+            if name in claimed:
+                # One shared helper base is harmless, two independent declarations of a name are not.
+                owner = _name_owner(extension, name)
+                if owner is None or owner is not _name_owner(claimed[name], name):
+                    raise ImproperlyConfigured(
+                        f"GraphQL extensions {_class_path(claimed[name])} and {_class_path(extension)} both "
+                        f"declare '{name}' on '{cls.__name__}'."
+                    )
+            claimed[name] = extension
+    return _compose(cls, extensions)
+
+
+def validate_extension_final_names(core_type, extensions):
+    """
+    Check extension fields against the final GraphQL names and python names of the decorated, unextended core
+    type. This covers names invisible before decoration: generated model fields, filter logical fields (AND,
+    OR, NOT, DISTINCT), and explicit aliases. NetBox disables auto camel casing, so a field's final name is
+    its explicit graphql_name or its python name.
+    """
+    baseline_fields = core_type.__strawberry_definition__.fields
+    baseline_names = {f.graphql_name or f.python_name for f in baseline_fields}
+    baseline_python_names = {f.python_name for f in baseline_fields}
+    claimed_names = {}
+    claimed_python_names = {}
+    for extension in extensions:
+        for field in extension.__strawberry_definition__.fields:
+            name = field.graphql_name or field.python_name
+            if name in baseline_names:
+                raise ImproperlyConfigured(
+                    f"GraphQL extension {_class_path(extension)} declares field '{name}', which collides with an "
+                    f"existing field of that name on '{core_type.__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
+            # An aliased field still claims its python name and would suppress the generated core field.
+            if field.python_name in baseline_python_names:
+                raise ImproperlyConfigured(
+                    f"GraphQL extension {_class_path(extension)} declares '{field.python_name}', which core type "
+                    f"'{core_type.__name__}' already provides."
+                )
+            # Strawberry keys fields by python name, so a shared one silently replaces across extensions.
+            if field.python_name in claimed_python_names:
+                raise ImproperlyConfigured(
+                    f"GraphQL extensions {_class_path(claimed_python_names[field.python_name])} and "
+                    f"{_class_path(extension)} both declare '{field.python_name}' on '{core_type.__name__}'."
+                )
+            if name in claimed_names:
+                raise ImproperlyConfigured(
+                    f"GraphQL extensions {_class_path(claimed_names[name])} and {_class_path(extension)} both "
+                    f"declare field '{name}' on '{core_type.__name__}'."
+                )
+            claimed_python_names[field.python_name] = extension
+            claimed_names[name] = extension
 
 
-def register_model_graphql_type(model, delegate, store_key, **kwargs):
+def validate_extension_targets():
+    """
+    Reject extensions whose target model never assembled a GraphQL type or filter, since they would otherwise
+    be silently discarded. Runs from the finalizer app after schema assembly.
     """
-    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`).
+    assembled = registry['plugins']['graphql_extensions_assembled']
+    for store in ('graphql_type_extensions', 'graphql_filter_extensions'):
+        for label, extensions in registry['plugins'][store].items():
+            if extensions and (store, label) not in assembled:
+                classes = ', '.join(_class_path(extension) for extension in extensions)
+                kind = 'output type' if store == 'graphql_type_extensions' else 'filter'
+                raise ImproperlyConfigured(
+                    f"GraphQL extension target '{label}' has no registered {kind} for extension(s): {classes}."
+                )
+
 
-    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.
+def register_model_graphql_type(model, delegate, store_key, **kwargs):
+    """
+    Decorator factory composing registered plugin extensions into a core GraphQL type or filter class. The
+    finalizer app assembles the schema during django.setup(), after every plugin has initialized, and
+    registering an extension once its target has assembled raises through the assembled-target set.
     """
     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.
+        own_is_type_of = vars(cls).get('is_type_of')
+        core_type = delegate(model, **kwargs)(cls)
         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)
+        if not extensions:
+            return core_type
+        validate_extension_final_names(core_type, extensions)
+        composed = splice_extension_bases(core_type, extensions)
+        # Only an is_type_of in the core's own body needs this, strawberry_django chains inherited ones itself.
+        if own_is_type_of is not None:
+            composed.is_type_of = own_is_type_of
+        return delegate(model, **kwargs)(composed)
 
     return wrapper

+ 30 - 15
netbox/netbox/plugins/__init__.py

@@ -1,7 +1,7 @@
 import collections
 from importlib import import_module
 
-from django.apps import AppConfig
+from django.apps import AppConfig, apps
 from django.core.exceptions import ImproperlyConfigured
 from django.utils.module_loading import import_string
 from packaging import version
@@ -24,8 +24,7 @@ registry['plugins'].update({
     '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.
+    # Assembled (store key, model label) pairs. Registering an extension for an assembled target raises.
     'graphql_extensions_assembled': set(),
     'menus': [],
     'menu_items': {},
@@ -38,9 +37,9 @@ DEFAULT_RESOURCE_PATHS = {
     'data_backends': 'data_backends.backends',
     'event_rule_actions': 'event_rules.event_rule_actions',
     'graphql_schema': 'graphql.schema',
+    'graphql_type_extensions': 'graphql_extensions.type_extensions',
+    'graphql_filter_extensions': 'graphql_extensions.filter_extensions',
     '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',
@@ -91,6 +90,7 @@ class PluginConfig(AppConfig):
     event_rule_actions = None
     graphql_schema = None
     jinja_filters = None
+    # Extension resources load from ready() and must not import core GraphQL modules. Schemas load at assembly.
     graphql_type_extensions = None
     graphql_filter_extensions = None
     menu = None
@@ -118,14 +118,16 @@ class PluginConfig(AppConfig):
         if path := getattr(self, name, None):
             return import_string(f"{self.__module__}.{path}")
 
-        # Fall back to the resource's default path. Return None if the module has not been provided.
+        # Fall back to the default path. Only the module's own absence returns None, nested errors propagate.
         default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
         default_module, resource_name = default_path.rsplit('.', 1)
         try:
             module = import_module(default_module)
-            return getattr(module, resource_name, None)
-        except ModuleNotFoundError:
-            pass
+        except ModuleNotFoundError as exc:
+            if exc.name and (default_module == exc.name or default_module.startswith(f'{exc.name}.')):
+                return None
+            raise
+        return getattr(module, resource_name, None)
 
     def ready(self):
         from netbox.models.features import register_models
@@ -164,12 +166,7 @@ class PluginConfig(AppConfig):
         if menu_items := self._load_resource('menu_items'):
             register_menu_items(self.verbose_name, menu_items)
 
-        # Register GraphQL schema (if defined)
-        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.
+        # Register GraphQL type & filter extensions (if defined)
         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'):
@@ -222,3 +219,21 @@ class PluginConfig(AppConfig):
         for setting, value in cls.default_settings.items():
             if setting not in user_config:
                 user_config[setting] = value
+
+
+def _load_plugin_graphql_schemas():
+    """
+    Load and register every installed plugin's GraphQL schema resource. Runs during root schema assembly, after
+    all plugins have initialized, so plugin schema modules may import core GraphQL types freely.
+    """
+    configs = {config.name: config for config in apps.get_app_configs()}
+    for plugin_name in registry['plugins']['installed']:
+        if (config := configs.get(plugin_name)) is None:
+            raise ImproperlyConfigured(
+                f"Plugin '{plugin_name}' has no AppConfig named after its PLUGINS entry. PluginConfig.name "
+                f"must match the configured plugin name."
+            )
+        if graphql_schema := config._load_resource('graphql_schema'):
+            # Avoid duplicate registration if the loader is invoked more than once.
+            registered = registry['plugins']['graphql_schemas']
+            register_graphql_schema([cls for cls in graphql_schema if cls not in registered])

+ 74 - 18
netbox/netbox/plugins/registration.py

@@ -2,6 +2,7 @@ import inspect
 import logging
 
 from django.apps import apps
+from django.core.exceptions import ImproperlyConfigured
 from django.utils.translation import gettext_lazy as _
 
 from netbox.graphql.utils import get_model_label
@@ -108,35 +109,70 @@ def register_graphql_schema(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.
+    Validate GraphQL extension classes and record them in the registry, bucketed by the canonical labels declared
+    in each class's `models` attribute. The whole list is validated before anything is recorded.
     """
+    staged = []
+    staged_pairs = set()
     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 isinstance(models, str):
+            raise TypeError(
+                _("GraphQL extension {extension} must declare 'models' as a list of labels, not a string.").format(
+                    extension=extension
+                )
+            )
+        try:
+            models = tuple(models or ())
+        except TypeError:
+            raise TypeError(
+                _("GraphQL extension {extension} must declare 'models' as an iterable of model labels.").format(
+                    extension=extension
+                )
+            ) from 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):
+        # Own __dict__ check so undecorated subclasses are rejected (Strawberry internal, pinned 0.323.2).
+        definition = vars(extension).get('__strawberry_definition__')
+        if definition is None:
             raise TypeError(
                 _("GraphQL extension {extension} must be decorated with @strawberry.type.").format(
                     extension=extension
                 )
             )
+        if definition.is_input or definition.is_interface or hasattr(extension, '__strawberry_django_definition__'):
+            raise TypeError(
+                _("GraphQL extension {extension} must be a plain @strawberry.type, not an input, an interface, "
+                  "or a strawberry_django type.").format(extension=extension)
+            )
+        if definition.interfaces:
+            raise TypeError(
+                _("GraphQL extension {extension} must not implement GraphQL interfaces.").format(
+                    extension=extension
+                )
+            )
+        if any(field.python_name == 'models' for field in definition.fields):
+            raise TypeError(
+                _("GraphQL extension {extension} must declare 'models' as an unannotated class attribute or "
+                  "ClassVar, not as a GraphQL field.").format(extension=extension)
+            )
+        seen = set()
+        canonical_labels = []
         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.
+            if not isinstance(label, str):
+                raise TypeError(
+                    _("GraphQL extension {extension} declares an invalid model label: {label!r}.").format(
+                        extension=extension, label=label
+                    )
+                )
             try:
                 model = apps.get_model(label)
             except (LookupError, ValueError):
@@ -146,15 +182,35 @@ def _register_graphql_extensions(class_list, store):
                     )
                 )
             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,
+            if canonical_label in seen:
+                raise TypeError(
+                    _("GraphQL extension {extension} declares duplicate label '{label}'.").format(
+                        extension=extension, label=canonical_label
+                    )
                 )
+            seen.add(canonical_label)
+            canonical_labels.append(canonical_label)
+        if any(
+            extension in registry['plugins'][store].get(label, ()) or (label, extension) in staged_pairs
+            for label in canonical_labels
+        ):
+            raise TypeError(
+                _("GraphQL extension {extension} is already registered.").format(extension=extension)
+            )
+        if assembled := [
+            label for label in canonical_labels
+            if (store, label) in registry['plugins']['graphql_extensions_assembled']
+        ]:
+            raise ImproperlyConfigured(
+                f"GraphQL extension {extension} for '{', '.join(assembled)}' was registered after the "
+                f"target GraphQL type was assembled. This usually means this or another plugin imported a core "
+                f"GraphQL module during plugin initialization. Reference core GraphQL types through "
+                f"strawberry.lazy() string annotations instead of importing them at module level."
+            )
+        staged.append((extension, canonical_labels))
+        staged_pairs.update((label, extension) for label in canonical_labels)
+    for extension, canonical_labels in staged:
+        for canonical_label in canonical_labels:
             registry['plugins'][store][canonical_label].append(extension)
 
 

+ 3 - 0
netbox/netbox/settings.py

@@ -1030,6 +1030,9 @@ for plugin_name in PLUGINS:
         else:
             raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
 
+# GraphQL assembly must run after every plugin has initialized.
+INSTALLED_APPS.append('netbox.graphql.apps.GraphQLConfig')
+
 
 #
 # Monkey-patching

+ 2 - 0
netbox/netbox/tests/dummy_plugin/broken_import.py

@@ -0,0 +1,2 @@
+"""Fixture module whose import fails, for _load_resource error propagation tests."""
+import netbox_missing_dependency  # noqa: F401

+ 4 - 33
netbox/netbox/tests/dummy_plugin/graphql.py

@@ -1,7 +1,9 @@
 
 import strawberry
 import strawberry_django
-from django.db.models import Q
+
+# Deliberate direct core-type import: schema modules load at assembly, after all extensions register.
+from dcim.graphql.types import SiteType
 
 from . import models
 
@@ -18,40 +20,9 @@ class DummyModelType:
 class DummyQuery:
     dummymodel: DummyModelType = strawberry_django.field()
     dummymodel_list: list[DummyModelType] = strawberry_django.field()
+    dummy_plugin_site_list: list[SiteType] = strawberry_django.field()
 
 
 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,
-]

+ 73 - 0
netbox/netbox/tests/dummy_plugin/graphql_extensions.py

@@ -0,0 +1,73 @@
+from typing import TYPE_CHECKING, Annotated
+
+import strawberry
+import strawberry_django
+from django.db.models import Q
+
+from utilities.querysets import RestrictedPrefetch
+
+from . import models
+
+if TYPE_CHECKING:
+    from dcim.graphql.types import SiteType
+
+#
+# Extensions to core GraphQL types & filters (see netbox.graphql.types.register_type /
+# netbox.graphql.filters.register_filter). These exercise the plugin extension point.
+#
+
+
+@strawberry_django.type(
+    models.DummySiteAttachment,
+    fields='__all__',
+)
+class DummySiteAttachmentType:
+    site: Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]
+
+
+@strawberry.type
+class SiteTypeExtension:
+    models = ['dcim.site']
+
+    @strawberry_django.field
+    def dummy_plugin_field(self) -> str:
+        return 'dummy-plugin-value'
+
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'dummy_site_attachments', info.context.request.user, 'view',
+            queryset=models.DummySiteAttachment.objects.all(),
+        ),
+    )
+    def dummy_site_attachments(self) -> list[Annotated[
+        'DummySiteAttachmentType', strawberry.lazy('netbox.tests.dummy_plugin.graphql_extensions')
+    ]]:
+        return self.dummy_site_attachments.all()
+
+
+@strawberry.type
+class RackReservationTypeExtension:
+    models = ['dcim.rackreservation']
+
+    @strawberry_django.field
+    def dummy_reservation_note(self) -> str:
+        return 'dummy-reservation-note'
+
+
+@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,
+    RackReservationTypeExtension,
+]
+
+filter_extensions = [
+    SiteFilterExtension,
+]

+ 30 - 0
netbox/netbox/tests/dummy_plugin/migrations/0003_dummysiteattachment.py

@@ -0,0 +1,30 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0250_cooling_infrastructure'),
+        ('dummy_plugin', '0002_dummynetboxmodel'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DummySiteAttachment',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=20)),
+                (
+                    'site',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='dummy_site_attachments',
+                        to='dcim.site',
+                    ),
+                ),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+    ]

+ 17 - 0
netbox/netbox/tests/dummy_plugin/models.py

@@ -1,6 +1,7 @@
 from django.db import models
 
 from netbox.models import NetBoxModel
+from utilities.querysets import RestrictedQuerySet
 
 
 class DummyModel(models.Model):
@@ -15,5 +16,21 @@ class DummyModel(models.Model):
         ordering = ['name']
 
 
+class DummySiteAttachment(models.Model):
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='dummy_site_attachments'
+    )
+    name = models.CharField(
+        max_length=20
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ['name']
+
+
 class DummyNetBoxModel(NetBoxModel):
     pass

+ 14 - 0
netbox/netbox/tests/dummy_plugin_b/__init__.py

@@ -0,0 +1,14 @@
+from netbox.plugins import PluginConfig
+
+
+class DummyPluginBConfig(PluginConfig):
+    name = 'netbox.tests.dummy_plugin_b'
+    verbose_name = 'Dummy plugin B'
+    version = '0.0'
+    description = 'For testing purposes only'
+    base_url = 'dummy-plugin-b'
+    min_version = '1.0'
+    max_version = '9.0'
+
+
+config = DummyPluginBConfig

+ 18 - 0
netbox/netbox/tests/dummy_plugin_b/graphql_extensions.py

@@ -0,0 +1,18 @@
+import strawberry
+import strawberry_django
+
+# Extends a type the earlier dummy_plugin's schema imports, anchoring the cross-plugin ordering contract.
+
+
+@strawberry.type
+class SiteTypeBExtension:
+    models = ['dcim.site']
+
+    @strawberry_django.field
+    def dummy_plugin_b_field(self) -> str:
+        return 'dummy-plugin-b-value'
+
+
+type_extensions = [
+    SiteTypeBExtension,
+]

+ 477 - 51
netbox/netbox/tests/test_graphql.py

@@ -1,10 +1,12 @@
 import json
 import re
-from unittest import skipIf
+from unittest import mock, skipIf
 
 import strawberry
+import strawberry_django
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ImproperlyConfigured
 from django.db import connection
 from django.test import override_settings
 from django.test.utils import CaptureQueriesContext
@@ -34,7 +36,10 @@ from ipam.models import RIR, Aggregate, IPAddress, Prefix
 from netbox.graphql.pagination import apply_distinct_window_pagination
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
-from users.models import Token, User
+from netbox.graphql.utils import register_model_graphql_type, splice_extension_bases, validate_extension_final_names
+from netbox.registry import registry
+from netbox.tests.dummy_plugin.models import DummySiteAttachment
+from users.models import ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
 from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 
@@ -178,6 +183,91 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(sites[0]['name'], 'Site 1')
         self.assertEqual(sites[0]['dummy_plugin_field'], 'dummy-plugin-value')
 
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_plugin_extension_preserves_get_queryset(self):
+        """
+        An extended core type keeps its own get_queryset() hook working, including its zero-argument super()
+        call and the unit_count annotation.
+        """
+        site = Site.objects.create(name='Reservation Site', slug='reservation-site')
+        rack = Rack.objects.create(name='Rack 1', site=site)
+        RackReservation.objects.create(rack=rack, units=[1, 2, 3], user=self.user, description='Test')
+
+        self.add_permissions('dcim.view_rackreservation')
+        url = reverse('graphql')
+        query = '{ rack_reservation_list { description units unit_count dummy_reservation_note } }'
+        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)
+        reservation = next(r for r in data['data']['rack_reservation_list'] if r['description'] == 'Test')
+        self.assertEqual(reservation['unit_count'], 3)
+        self.assertEqual(reservation['dummy_reservation_note'], 'dummy-reservation-note')
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_plugin_reverse_relation_scoped(self):
+        """
+        A plugin-provided reverse relation resolves through RestrictedPrefetch and returns only related objects
+        the requesting user may view.
+        """
+        site = Site.objects.create(name='Attachment Site', slug='attachment-site')
+        DummySiteAttachment.objects.create(site=site, name='Attachment A')
+        DummySiteAttachment.objects.create(site=site, name='Attachment B')
+
+        self.add_permissions('dcim.view_site')
+        obj_perm = ObjectPermission(name='Attachment view', actions=['view'], constraints={'name': 'Attachment A'})
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(DummySiteAttachment))
+
+        url = reverse('graphql')
+        query = (
+            '{ site_list(filters: {name: {exact: "Attachment Site"}}) '
+            '{ name dummy_site_attachments { name } } }'
+        )
+        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)
+        attachments = data['data']['site_list'][0]['dummy_site_attachments']
+        self.assertEqual([a['name'] for a in attachments], ['Attachment A'])
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_plugin_schema_query_executes(self):
+        """
+        A plugin-provided top-level query field returning a core type executes, proving plugin schemas load
+        at assembly time with extensions applied.
+        """
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+        query = '{ dummy_plugin_site_list { 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)
+        site = next(s for s in data['data']['dummy_plugin_site_list'] if s['name'] == 'Site 1')
+        self.assertEqual(site['dummy_plugin_field'], 'dummy-plugin-value')
+
+    @skipIf('netbox.tests.dummy_plugin_b' not in settings.PLUGINS, "dummy_plugin_b not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_cross_plugin_extension_applies(self):
+        """
+        An extension registered by a later plugin applies to a core type that an earlier plugin's schema
+        module imports, proving later plugin extensions are registered before earlier plugin schemas load.
+        """
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+        query = '{ dummy_plugin_site_list { name dummy_plugin_b_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)
+        site = next(s for s in data['data']['dummy_plugin_site_list'] if s['name'] == 'Site 1')
+        self.assertEqual(site['dummy_plugin_b_field'], 'dummy-plugin-b-value')
+
     @override_settings(LOGIN_REQUIRED=True)
     def test_graphql_filter_objects(self):
         """
@@ -1376,7 +1466,7 @@ class JSONStringLookupTestCase(TestCase):
 
 
 class SpliceExtensionBasesTestCase(TestCase):
-    """Verify splice_extension_bases() behavior: pass-through, splicing, and collision warnings."""
+    """Verify splice_extension_bases() composition and the strictly additive extension contract."""
 
     @staticmethod
     def _make_core():
@@ -1395,14 +1485,11 @@ class SpliceExtensionBasesTestCase(TestCase):
         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']
@@ -1413,90 +1500,429 @@ class SpliceExtensionBasesTestCase(TestCase):
         self.assertIsNot(result, CoreType)
         self.assertEqual(result.__name__, CoreType.__name__)
         self.assertIn(Extension, result.__mro__)
+        self.assertTrue(issubclass(result, CoreType))
         # 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
-
+    def test_extension_cannot_shadow_core_own_field(self):
         @strawberry.type
         class Extension:
             models = ['dcim.device']
-            name: str  # collides with CoreType.name (own body)
+            name: str
 
-        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))
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [Extension])
 
-    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
+    def test_extension_cannot_shadow_inherited_field(self):
+        @strawberry.type
+        class Extension:
+            models = ['dcim.device']
+            description: str
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [Extension])
 
+    def test_extension_cannot_shadow_core_hook(self):
         @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))
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return queryset
 
-    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
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [Extension])
 
+    def test_two_extensions_cannot_declare_same_new_field(self):
         @strawberry.type
-        class Extension:
+        class ExtensionA:
             models = ['dcim.device']
+            widgets: str
 
+        @strawberry.type
+        class ExtensionB:
+            models = ['dcim.device']
+            widgets: str
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
+
+    def test_extension_cannot_interleave_ahead_of_core_ancestor(self):
+        """Shared ancestry is rejected because C3 could resolve an inherited hook to the extension side."""
+        class CoreBase:
             @classmethod
             def get_queryset(cls, queryset, info, **kwargs):
-                return 'EXTENSION_WON'
+                return 'core'
 
-        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')
+        class CoreType(CoreBase):
+            pass
+
+        class ExtensionBase(CoreBase):
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return 'plugin'
+
+        @strawberry.type
+        class Extension(ExtensionBase):
+            models = ['dcim.site']
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(CoreType, [Extension])
+
+    def test_extension_cannot_inherit_core_hook_name(self):
+        """An inherited plain method colliding with a core name fails instead of being silently ignored."""
+        class PluginBase:
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return queryset
+
+        @strawberry.type
+        class Extension(PluginBase):
+            models = ['dcim.site']
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [Extension])
+
+    def test_extensions_may_share_a_helper_base(self):
+        """Two extensions inheriting the same helper method compose without a collision error."""
+        class Helper:
+            @classmethod
+            def _helper(cls):
+                return 'x'
+
+        @strawberry.type
+        class ExtensionA(Helper):
+            models = ['dcim.site']
+            field_a: str
+
+        @strawberry.type
+        class ExtensionB(Helper):
+            models = ['dcim.site']
+            field_b: str
+
+        composed = splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
+        self.assertTrue(issubclass(composed, ExtensionA))
+
+    def test_extension_may_inherit_annotation_only_name_from_plain_base(self):
+        """An annotation on a plain base is neither a Strawberry field nor an attribute, so it shadows nothing."""
+        class Helper:
+            name: str
+
+        @strawberry.type
+        class Extension(Helper):
+            models = ['dcim.site']
+            plugin_field: str
+
+        composed = splice_extension_bases(self._make_core(), [Extension])
+        self.assertTrue(issubclass(composed, Extension))
+
+    def test_extensions_may_share_a_plain_annotated_helper_base(self):
+        """Two extensions inheriting one annotation-only helper base do not collide with each other."""
+        class Helper:
+            internal_state: str
+
+        @strawberry.type
+        class ExtensionA(Helper):
+            models = ['dcim.site']
+            field_a: str
+
+        @strawberry.type
+        class ExtensionB(Helper):
+            models = ['dcim.site']
+            field_b: str
+
+        composed = splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
+        self.assertTrue(issubclass(composed, ExtensionA))
+        self.assertTrue(issubclass(composed, ExtensionB))
+
+    def test_two_extensions_cannot_bind_the_same_helper_function(self):
+        """Sharing one base is legal, but two declarations of a name are not, even for the same function object."""
+        def shared_helper(self):
+            return 'x'
+
+        @strawberry.type
+        class ExtensionA:
+            models = ['dcim.site']
+            field_a: str
+            helper = shared_helper
+
+        @strawberry.type
+        class ExtensionB:
+            models = ['dcim.site']
+            field_b: str
+            helper = shared_helper
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
+
+    def test_extension_cannot_inherit_field_named_for_core_hook(self):
+        """A field inherited from a decorated base still collides with a core name that is only a plain hook."""
+        @strawberry.type
+        class ExtensionBase:
+            get_queryset: str
+
+        @strawberry.type
+        class Extension(ExtensionBase):
+            models = ['dcim.site']
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [Extension])
 
-    def test_mro_conflict_raises_clear_error(self):
-        from netbox.graphql.utils import splice_extension_bases
+    def test_extensions_cannot_collide_via_inherited_field_and_plain_method(self):
+        """One extension's inherited Strawberry field collides with another's plain method of the same name."""
+        @strawberry.type
+        class BaseWithField:
+            shared: str
+
+        @strawberry.type
+        class ExtensionA(BaseWithField):
+            models = ['dcim.site']
 
+        @strawberry.type
+        class ExtensionB:
+            models = ['dcim.site']
+
+            def shared(self):
+                return 'x'
+
+        with self.assertRaises(ImproperlyConfigured):
+            splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
+
+    def test_conflicting_extension_bases_raise_clear_error(self):
         class A:
             pass
 
         class B:
             pass
 
-        class Core(A, B):
+        class Core:
             name = 'core'
 
-        class Extension(B, A):  # reversed base order -> inconsistent MRO when spliced
+        @strawberry.type
+        class Ext1(A, B):
             models = ['dcim.device']
+            field_1: str
+
+        @strawberry.type
+        class Ext2(B, A):
+            models = ['dcim.device']
+            field_2: str
+
+        with self.assertRaises(ImproperlyConfigured) as ctx:
+            splice_extension_bases(Core, [Ext1, Ext2])
+        self.assertIn('Failed to compose', str(ctx.exception))
+
+    def test_zero_arg_super_and_own_fields_survive_composition(self):
+        """Composition preserves the core class's own annotated fields and its zero-argument super() calls."""
+        @strawberry.type
+        class Extension:
+            models = ['dcim.rackreservation']
 
-        with self.assertRaises(TypeError) as ctx:
-            splice_extension_bases(Core, [Extension])
-        self.assertIn('Failed to splice', str(ctx.exception))
+            @strawberry_django.field
+            def extension_field(self) -> str:
+                return 'x'
 
-    def test_warns_on_collision_between_extensions(self):
-        from netbox.graphql.utils import splice_extension_bases
+        @strawberry.type
+        class CoreBase:
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return queryset
+
+        class RackReservationProto(CoreBase):
+            units: list[int]
+            description: str
 
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return super().get_queryset(queryset, info, **kwargs)
+
+        core_type = strawberry_django.type(RackReservation, fields='__all__')(RackReservationProto)
+        composed = splice_extension_bases(core_type, [Extension])
+        self.assertTrue(issubclass(composed, RackReservationProto))
+        result = strawberry_django.type(RackReservation, fields='__all__')(composed)
+        names = {f.name for f in result.__strawberry_definition__.fields}
+        self.assertIn('units', names)
+        self.assertIn('description', names)
+        self.assertIn('extension_field', names)
+        self.assertIs(result.get_queryset('QS', None), 'QS')
+
+    def test_extension_cannot_replace_generated_model_field(self):
+        @strawberry.type
+        class CoreBase:
+            pass
+
+        class CoreType(CoreBase):
+            pass
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            name: str
+
+        core_type = strawberry_django.type(Site, fields='__all__')(CoreType)
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_type, [Extension])
+
+    def test_extension_cannot_alias_onto_existing_name(self):
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+
+            @strawberry.field(name='description')
+            def plugin_description(self) -> str:
+                return 'x'
+
+        core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_type, [Extension])
+
+    def test_extension_cannot_alias_away_generated_model_field(self):
+        """An aliased extension field still claims its python name, which would suppress the generated field."""
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            slug: str = strawberry.field(name='plugin_slug')
+
+        core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_type, [Extension])
+
+    def test_filter_extension_cannot_alias_away_core_filter_field(self):
+        """The python-name check applies to the filter path as well."""
+        class CoreFilter:
+            name: str | None = strawberry_django.filter_field()
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            name: str | None = strawberry_django.filter_field(name='plugin_name')
+
+        core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_filter, [Extension])
+
+    def test_filter_extension_cannot_alias_away_logical_field(self):
+        """A generated logical filter field is protected from python-name capture through an alias."""
+        class CoreFilter:
+            pass
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            AND: str | None = strawberry_django.filter_field(name='plugin_and')
+
+        core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_filter, [Extension])
+
+    def test_extension_cannot_alias_onto_core_filter_alias(self):
+        class CoreFilter:
+            _custom: str | None = strawberry_django.filter_field(name='custom')
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            custom: str | None = strawberry_django.filter_field()
+
+        core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_filter, [Extension])
+
+    def test_extension_cannot_redefine_filter_logical_fields(self):
+        class CoreFilter:
+            pass
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.site']
+            AND: str | None = None
+
+        core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_filter, [Extension])
+
+    def test_two_extensions_cannot_inherit_same_python_name(self):
+        """Inherited fields collide by python name even when their GraphQL aliases differ."""
+        @strawberry.type
+        class ExtensionBaseA:
+            value: str = strawberry.field(name='plugin_a_value')
+
+        @strawberry.type
+        class ExtensionA(ExtensionBaseA):
+            models = ['dcim.site']
+
+        @strawberry.type
+        class ExtensionBaseB:
+            value: str = strawberry.field(name='plugin_b_value')
+
+        @strawberry.type
+        class ExtensionB(ExtensionBaseB):
+            models = ['dcim.site']
+
+        core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_type, [ExtensionA, ExtensionB])
+
+    def test_extension_composition_preserves_core_is_type_of(self):
+        """A core-defined is_type_of survives composition instead of being shadowed by the injected default."""
+        @strawberry.type
+        class Extension:
+            models = ['dcim.rackreservation']
+
+            @strawberry_django.field
+            def marker_field(self) -> str:
+                return 'x'
+
+        @strawberry.type
+        class CoreBase:
+            pass
+
+        class Proto(CoreBase):
+            @classmethod
+            def is_type_of(cls, obj, info):
+                return True
+
+        custom = vars(Proto)['is_type_of']
+        with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.rackreservation': [Extension]}):
+            final = register_model_graphql_type(
+                RackReservation, strawberry_django.type, 'graphql_type_extensions', fields='__all__'
+            )(Proto)
+        self.assertIs(vars(final).get('is_type_of'), custom)
+
+    def test_defaulted_extension_field_composes_onto_required_core_fields(self):
+        """Strawberry keyword-only fields let a defaulted extension field precede required core fields."""
+        @strawberry.type
+        class Extension:
+            models = ['dcim.rackreservation']
+            plugin_note: str = strawberry.field(default='note')
+
+        @strawberry.type
+        class Proto:
+            pass
+
+        with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.rackreservation': [Extension]}):
+            final = register_model_graphql_type(
+                RackReservation, strawberry_django.type, 'graphql_type_extensions', fields='__all__'
+            )(Proto)
+        names = {field.python_name for field in final.__strawberry_definition__.fields}
+        self.assertIn('plugin_note', names)
+        self.assertIn('units', names)
+
+    def test_two_extensions_cannot_alias_same_final_name(self):
         @strawberry.type
         class ExtensionA:
-            models = ['dcim.device']
+            models = ['dcim.site']
             widgets: str
 
         @strawberry.type
         class ExtensionB:
-            models = ['dcim.device']
-            widgets: str
+            models = ['dcim.site']
 
-        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))
+            @strawberry.field(name='widgets')
+            def other_name(self) -> str:
+                return 'x'
+
+        core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
+        with self.assertRaises(ImproperlyConfigured):
+            validate_extension_final_names(core_type, [ExtensionA, ExtensionB])

+ 236 - 22
netbox/netbox/tests/test_plugins.py

@@ -1,6 +1,11 @@
 import re
-from unittest import skipIf
+import subprocess
+import sys
+from unittest import mock, skipIf
 
+import strawberry
+import strawberry_django
+from django.apps import apps
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.test import Client, TestCase, override_settings
@@ -8,14 +13,20 @@ from django.urls import reverse
 
 from core.choices import JobIntervalChoices
 from core.models import ObjectType
+from dcim.models import Site
 from extras.dashboard.widgets import DashboardWidget
+from netbox.graphql import utils as graphql_utils
 from netbox.graphql.schema import Query
+from netbox.graphql.utils import register_model_graphql_type
+from netbox.plugins import DEFAULT_RESOURCE_PATHS, _load_plugin_graphql_schemas
 from netbox.plugins.navigation import PluginMenu, PluginMenuButton, PluginMenuItem
+from netbox.plugins.registration import register_graphql_type_extensions
 from netbox.plugins.utils import get_plugin_config
 from netbox.registry import registry
 from netbox.tests.dummy_plugin import config as dummy_config
 from netbox.tests.dummy_plugin.data_backends import DummyBackend
 from netbox.tests.dummy_plugin.jobs import DummySystemJob
+from netbox.tests.dummy_plugin.models import DummyModel
 from netbox.tests.dummy_plugin.webhook_callbacks import set_context
 
 
@@ -118,7 +129,6 @@ class PluginTestCase(TestCase):
         """
         Check that a plugin can register a custom column on a core model table.
         """
-        from dcim.models import Site
         from dcim.tables import SiteTable
 
         table = SiteTable(Site.objects.all())
@@ -222,7 +232,7 @@ class PluginTestCase(TestCase):
         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
+        from netbox.tests.dummy_plugin.graphql_extensions import SiteFilterExtension, SiteTypeExtension
 
         # Extensions are registered against the targeted core model
         self.assertIn(SiteTypeExtension, registry['plugins']['graphql_type_extensions']['dcim.site'])
@@ -237,6 +247,63 @@ class PluginTestCase(TestCase):
         self.assertIsNotNone(site_filter, "SiteFilter not found in GraphQL schema")
         self.assertIn('dummy_plugin_filter', site_filter.group(0))
 
+    def test_load_resource_returns_none_for_missing_default_module(self):
+        config = apps.get_app_config('dummy_plugin')
+        with mock.patch.dict(DEFAULT_RESOURCE_PATHS, {'graphql_type_extensions': 'no_such_module.type_extensions'}):
+            self.assertIsNone(config._load_resource('graphql_type_extensions'))
+
+    def test_load_resource_propagates_nested_import_error(self):
+        config = apps.get_app_config('dummy_plugin')
+        with mock.patch.dict(DEFAULT_RESOURCE_PATHS, {'graphql_type_extensions': 'broken_import.type_extensions'}):
+            with self.assertRaises(ModuleNotFoundError):
+                config._load_resource('graphql_type_extensions')
+
+    def test_graphql_finalizer_app_installed_after_plugins(self):
+        finalizer = settings.INSTALLED_APPS.index('netbox.graphql.apps.GraphQLConfig')
+        plugin_positions = [i for i, app in enumerate(settings.INSTALLED_APPS) if 'dummy_plugin' in app]
+        self.assertTrue(plugin_positions)
+        self.assertGreater(finalizer, max(plugin_positions))
+        self.assertEqual(apps.get_app_config('netbox_graphql').name, 'netbox.graphql')
+
+    def test_graphql_finalizer_runs_during_django_setup(self):
+        """The finalizer assembles the schema before auditing targets, and its errors fail django.setup()."""
+        # Source for a child interpreter, so it carries no indentation of its own.
+        script = """
+import sys
+import django
+from netbox.graphql import utils
+
+
+def audit():
+    if 'netbox.graphql.schema' not in sys.modules:
+        raise RuntimeError('SCHEMA_NOT_ASSEMBLED')
+    raise RuntimeError('AUDIT_RAN_AFTER_SCHEMA')
+
+
+utils.validate_extension_targets = audit
+django.setup()
+"""
+        # The child inherits this process's settings, which is safe only because ready() touches no database.
+        result = subprocess.run(
+            [sys.executable, '-c', script], capture_output=True, text=True, cwd=settings.BASE_DIR, timeout=300
+        )
+        self.assertNotEqual(result.returncode, 0, f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}")
+        self.assertIn('AUDIT_RAN_AFTER_SCHEMA', result.stderr)
+
+    def test_missing_plugin_app_config_raises_clear_error(self):
+        installed = [*registry['plugins']['installed'], 'not_a_real_plugin']
+        with mock.patch.dict(registry['plugins'], {'installed': installed}):
+            with self.assertRaises(ImproperlyConfigured) as ctx:
+                _load_plugin_graphql_schemas()
+        self.assertIn('not_a_real_plugin', str(ctx.exception))
+
+    def test_plugin_schema_loading_is_idempotent(self):
+        before = list(registry['plugins']['graphql_schemas'])
+        self.addCleanup(lambda: registry['plugins']['graphql_schemas'].__setitem__(slice(None), before))
+        _load_plugin_graphql_schemas()
+        _load_plugin_graphql_schemas()
+        self.assertEqual(registry['plugins']['graphql_schemas'], before)
+
     @override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}})
     def test_get_plugin_config(self):
         """
@@ -445,28 +512,175 @@ class RegisterGraphQLExtensionsTestCase(TestCase):
         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
-
+    def test_raises_when_registered_after_assembly(self):
         @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:
+        with self.assertRaises(ImproperlyConfigured) as ctx:
             register_graphql_type_extensions([LateExt])
-        self.assertTrue(any('after the core type was assembled' in msg for msg in cm.output))
+        self.assertIn('strawberry.lazy()', str(ctx.exception))
+        self.assertNotIn(LateExt, registry['plugins']['graphql_type_extensions'].get('dcim.cable', []))
+
+    def test_rejects_string_models(self):
+        @strawberry.type
+        class Ext:
+            models = 'dcim.site'
+            field_a: str
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_duplicate_labels(self):
+        @strawberry.type
+        class Ext:
+            models = ['dcim.site', 'dcim.Site']
+            field_a: str
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_subclass_of_strawberry_django_type(self):
+        @strawberry_django.type(DummyModel, fields='__all__')
+        class Base:
+            pass
+
+        @strawberry.type
+        class Ext(Base):
+            models = ['dcim.site']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_duplicate_registration(self):
+        @strawberry.type
+        class Ext:
+            models = ['dcim.cable']
+            field_a: str
+
+        with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.cable': [Ext]}):
+            with self.assertRaises(TypeError):
+                register_graphql_type_extensions([Ext])
+
+    def test_rejects_interface_implementing_extension(self):
+        @strawberry.interface
+        class PluginInterface:
+            plugin_field: str
+
+        @strawberry.type
+        class Ext(PluginInterface):
+            models = ['dcim.site']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_extension_instance(self):
+        @strawberry.type
+        class Ext:
+            models = ['dcim.site']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext()])
+
+    def test_rejects_non_string_model_label(self):
+        @strawberry.type
+        class Ext:
+            models = [None]
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_input_type_extension(self):
+        @strawberry.input
+        class Ext:
+            models = ['dcim.site']
+            field_a: str
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_interface_extension(self):
+        @strawberry.interface
+        class Ext:
+            models = ['dcim.site']
+            field_a: str
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejects_strawberry_django_type_extension(self):
+        @strawberry_django.type(DummyModel, fields='__all__')
+        class Ext:
+            models = ['dcim.site']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_core_assembly_allowed_during_app_initialization(self):
+        """Core types still assemble while apps are initializing, since the old apps.ready gate was removed."""
+        with (
+            mock.patch.object(apps, 'ready', False),
+            mock.patch.dict(registry['plugins']['graphql_type_extensions'], {}, clear=True),
+        ):
+            @register_model_graphql_type(Site, strawberry_django.type, 'graphql_type_extensions', fields='__all__')
+            class TestSiteType:
+                pass
+
+        self.assertTrue(hasattr(TestSiteType, '__strawberry_definition__'))
+
+    def test_rejects_extension_with_unassembled_target(self):
+        @strawberry.type
+        class Ext:
+            models = ['dcim.cablepath']
+            field_a: str
+
+        with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.cablepath': [Ext]}):
+            with self.assertRaises(ImproperlyConfigured) as ctx:
+                graphql_utils.validate_extension_targets()
+        self.assertIn('dcim.cablepath', str(ctx.exception))
+
+    def test_extension_targets_validate_on_real_state(self):
+        graphql_utils.validate_extension_targets()
+
+    def test_rejects_annotated_models_marker(self):
+        @strawberry.type
+        class Ext:
+            models: tuple[str, ...] = ('dcim.site',)
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Ext])
+
+    def test_rejected_registration_creates_no_registry_bucket(self):
+        @strawberry.type
+        class Ext:
+            models = ['dcim.cable']
+            field_a: str
+
+        with self.assertRaises(ImproperlyConfigured):
+            register_graphql_type_extensions([Ext])
+        self.assertNotIn('dcim.cable', registry['plugins']['graphql_type_extensions'])
+
+    def test_rejects_non_iterable_models(self):
+        @strawberry.type
+        class Ext:
+            models = 5
+
+        with self.assertRaises(TypeError) as ctx:
+            register_graphql_type_extensions([Ext])
+        self.assertIn("must declare 'models'", str(ctx.exception))
+
+    def test_invalid_batch_entry_registers_nothing(self):
+        self.addCleanup(lambda: registry['plugins']['graphql_type_extensions'].pop('dcim.cablepath', None))
+
+        @strawberry.type
+        class GoodExt:
+            models = ['dcim.cablepath']
+            field_a: str
+
+        class BadExt:
+            models = ['dcim.cablepath']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([GoodExt, BadExt])
+        self.assertNotIn(GoodExt, registry['plugins']['graphql_type_extensions'].get('dcim.cablepath', ()))