#22592: Pre-release QA (#22920)

This commit is contained in:
Martin Hauser 2026-08-14 18:09:53 +02:00 committed by GitHub
parent 84d0cdad63
commit f4fdd60e8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1171 additions and 229 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 _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.
"""
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
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.
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
# 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
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 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.
"""
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}."
)
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.
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,79 +1500,41 @@ 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))
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
with self.assertRaises(ImproperlyConfigured):
splice_extension_bases(self._make_core(), [Extension])
def test_extension_cannot_shadow_inherited_field(self):
@strawberry.type
class Extension:
models = ['dcim.device']
description: str # collides with CoreBase.description (inherited)
description: 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 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
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']
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
return 'EXTENSION_WON'
return queryset
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
with self.assertRaises(ImproperlyConfigured):
splice_extension_bases(self._make_core(), [Extension])
def test_two_extensions_cannot_declare_same_new_field(self):
@strawberry.type
class ExtensionA:
models = ['dcim.device']
@ -1496,7 +1545,384 @@ class SpliceExtensionBasesTestCase(TestCase):
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))
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 'core'
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_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:
name = 'core'
@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']
@strawberry_django.field
def extension_field(self) -> str:
return 'x'
@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.site']
widgets: str
@strawberry.type
class ExtensionB:
models = ['dcim.site']
@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])

View File

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