Coverage for netbox/plugins/registration.py: 13%
104 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import inspect
2import logging
4from django.apps import apps
5from django.core.exceptions import ImproperlyConfigured
6from django.utils.translation import gettext_lazy as _
8from netbox.graphql.utils import get_model_label
9from netbox.registry import registry
11from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
12from .templates import PluginTemplateExtension
14logger = logging.getLogger(__name__)
16__all__ = (
17 'register_graphql_filter_extensions',
18 'register_graphql_schema',
19 'register_graphql_type_extensions',
20 'register_jinja_filters',
21 'register_menu',
22 'register_menu_items',
23 'register_serializer_resolver',
24 'register_template_extensions',
25 'register_user_preferences',
26)
29def register_jinja_filters(filters):
30 """
31 Register a dict of Jinja filter functions provided by a plugin. Each key is the
32 filter name as it will appear in templates; the value is the callable implementing it.
33 Plugin-registered filters have lower precedence than instance-level JINJA_FILTERS
34 so that site admins can always override them in configuration.py.
35 """
36 if not isinstance(filters, dict):
37 raise TypeError(_("jinja_filters must be a dict mapping filter names to callables"))
38 for name, fn in filters.items():
39 if not callable(fn):
40 raise TypeError(_("Jinja filter '{name}' must be callable").format(name=name))
41 if name in registry['plugins']['jinja_filters']:
42 logger.warning(
43 "Jinja filter '%s' registered by a plugin is being overridden by a later-loaded plugin",
44 name,
45 )
46 registry['plugins']['jinja_filters'].update(filters)
49def register_template_extensions(class_list):
50 """
51 Register a list of PluginTemplateExtension classes
52 """
53 for template_extension in class_list:
54 # Validation
55 if not inspect.isclass(template_extension):
56 raise TypeError(
57 _("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
58 template_extension=template_extension
59 )
60 )
61 if not issubclass(template_extension, PluginTemplateExtension):
62 raise TypeError(
63 _("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
64 template_extension=template_extension
65 )
66 )
68 if template_extension.models:
69 # Registration for specific models
70 models = template_extension.models
71 else:
72 # Global registration (no specific models)
73 models = [None]
74 for model in models:
75 registry['plugins']['template_extensions'][model].append(template_extension)
78def register_menu(menu):
79 if not isinstance(menu, PluginMenu):
80 raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(item=menu))
81 registry['plugins']['menus'].append(menu)
84def register_menu_items(section_name, class_list):
85 """
86 Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
87 """
88 # Validation
89 for menu_link in class_list:
90 if not isinstance(menu_link, PluginMenuItem):
91 raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
92 menu_link=menu_link
93 ))
94 for button in menu_link.buttons:
95 if not isinstance(button, PluginMenuButton):
96 raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
97 button=button
98 ))
100 registry['plugins']['menu_items'][section_name] = class_list
103def register_graphql_schema(graphql_schema):
104 """
105 Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
106 """
107 registry['plugins']['graphql_schemas'].extend(graphql_schema)
110def _register_graphql_extensions(class_list, store):
111 """
112 Validate GraphQL extension classes and record them in the registry, bucketed by the canonical labels declared
113 in each class's `models` attribute. The whole list is validated before anything is recorded.
114 """
115 staged = []
116 staged_pairs = set()
117 for extension in class_list:
118 if not inspect.isclass(extension):
119 raise TypeError(
120 _("GraphQL extension {extension} was passed as an instance!").format(extension=extension)
121 )
122 models = getattr(extension, 'models', None)
123 if isinstance(models, str):
124 raise TypeError(
125 _("GraphQL extension {extension} must declare 'models' as a list of labels, not a string.").format(
126 extension=extension
127 )
128 )
129 try:
130 models = tuple(models or ())
131 except TypeError:
132 raise TypeError(
133 _("GraphQL extension {extension} must declare 'models' as an iterable of model labels.").format(
134 extension=extension
135 )
136 ) from None
137 if not models:
138 raise TypeError(
139 _("GraphQL extension {extension} must declare a non-empty 'models' attribute.").format(
140 extension=extension
141 )
142 )
143 # Own __dict__ check so undecorated subclasses are rejected (Strawberry internal, pinned 0.323.2).
144 definition = vars(extension).get('__strawberry_definition__')
145 if definition is None:
146 raise TypeError(
147 _("GraphQL extension {extension} must be decorated with @strawberry.type.").format(
148 extension=extension
149 )
150 )
151 if definition.is_input or definition.is_interface or hasattr(extension, '__strawberry_django_definition__'):
152 raise TypeError(
153 _("GraphQL extension {extension} must be a plain @strawberry.type, not an input, an interface, "
154 "or a strawberry_django type.").format(extension=extension)
155 )
156 if definition.interfaces:
157 raise TypeError(
158 _("GraphQL extension {extension} must not implement GraphQL interfaces.").format(
159 extension=extension
160 )
161 )
162 if any(field.python_name == 'models' for field in definition.fields):
163 raise TypeError(
164 _("GraphQL extension {extension} must declare 'models' as an unannotated class attribute or "
165 "ClassVar, not as a GraphQL field.").format(extension=extension)
166 )
167 seen = set()
168 canonical_labels = []
169 for label in models:
170 if not isinstance(label, str):
171 raise TypeError(
172 _("GraphQL extension {extension} declares an invalid model label: {label!r}.").format(
173 extension=extension, label=label
174 )
175 )
176 try:
177 model = apps.get_model(label)
178 except (LookupError, ValueError):
179 raise TypeError(
180 _("GraphQL extension {extension} targets unknown model '{label}'.").format(
181 extension=extension, label=label
182 )
183 )
184 canonical_label = get_model_label(model)
185 if canonical_label in seen:
186 raise TypeError(
187 _("GraphQL extension {extension} declares duplicate label '{label}'.").format(
188 extension=extension, label=canonical_label
189 )
190 )
191 seen.add(canonical_label)
192 canonical_labels.append(canonical_label)
193 if any(
194 extension in registry['plugins'][store].get(label, ()) or (label, extension) in staged_pairs
195 for label in canonical_labels
196 ):
197 raise TypeError(
198 _("GraphQL extension {extension} is already registered.").format(extension=extension)
199 )
200 if assembled := [
201 label for label in canonical_labels
202 if (store, label) in registry['plugins']['graphql_extensions_assembled']
203 ]:
204 raise ImproperlyConfigured(
205 f"GraphQL extension {extension} for '{', '.join(assembled)}' was registered after the "
206 f"target GraphQL type was assembled. This usually means this or another plugin imported a core "
207 f"GraphQL module during plugin initialization. Reference core GraphQL types through "
208 f"strawberry.lazy() string annotations instead of importing them at module level."
209 )
210 staged.append((extension, canonical_labels))
211 staged_pairs.update((label, extension) for label in canonical_labels)
212 for extension, canonical_labels in staged:
213 for canonical_label in canonical_labels:
214 registry['plugins'][store][canonical_label].append(extension)
217def register_graphql_type_extensions(class_list):
218 """
219 Register a list of GraphQL output-type mixin classes. Each class must be decorated with @strawberry.type and
220 declare a `models` attribute listing the `app_label.model` labels of the core types it extends.
221 """
222 _register_graphql_extensions(class_list, 'graphql_type_extensions')
225def register_graphql_filter_extensions(class_list):
226 """
227 Register a list of GraphQL filter mixin classes. Each class must be decorated with @strawberry.type and declare
228 a `models` attribute listing the `app_label.model` labels of the core filters it extends.
229 """
230 _register_graphql_extensions(class_list, 'graphql_filter_extensions')
233def register_user_preferences(plugin_name, preferences):
234 """
235 Register a list of user preferences defined by a plugin.
236 """
237 registry['plugins']['preferences'][plugin_name] = preferences
240def register_serializer_resolver(app_label, resolver):
241 """
242 Register a callable that returns a DRF serializer class for a model in
243 the given app, or None if the resolver does not handle the model. The
244 resolver is consulted by utilities.api.get_serializer_for_model() before
245 the default import-path lookup, but only for models belonging to
246 `app_label`. Plugins (and internal apps) should only register resolvers
247 for their own models.
249 This is the supported extension point for plugins whose models are
250 generated dynamically (and therefore have no importable serializer at
251 the {app_label}.api.serializers.{Model}Serializer path) or that need
252 to override serializer resolution for specific models.
254 Resolver signature: resolver(model, prefix='') -> serializer class or None
255 """
256 if not callable(resolver):
257 raise TypeError(_("Serializer resolver must be callable"))
258 if app_label in registry['serializer_resolvers']:
259 raise ValueError(
260 _("A serializer resolver is already registered for app '{app_label}'").format(app_label=app_label)
261 )
262 registry['serializer_resolvers'][app_label] = resolver