cleanup
This commit is contained in:
parent
0499bb7616
commit
b09e8a1808
|
|
@ -122,6 +122,7 @@ 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` | 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`) |
|
| `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_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
|
||||||
|
| `serializer_resolver` | The dotted path to a callable that resolves REST API serializers for the plugin's models (default: `api.serializers.serializer_resolver`) |
|
||||||
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
|
| `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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -95,5 +95,13 @@ urlpatterns = router.urls
|
||||||
|
|
||||||
This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`.
|
This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`.
|
||||||
|
|
||||||
|
## Serializer Resolvers
|
||||||
|
|
||||||
|
NetBox resolves the REST API serializer for a model by importing `{app_label}.api.serializers.{ModelName}Serializer`. Plugins whose models are generated dynamically (and therefore have no importable serializer at that path), or that need to override resolution for specific models, may register a **serializer resolver**.
|
||||||
|
|
||||||
|
A resolver is a callable with the signature `resolver(model, prefix='') -> Serializer subclass or None`. Resolvers are consulted in registration order before the default import-path lookup; the first non-`None` return wins. If no resolver matches, the default lookup runs unchanged.
|
||||||
|
|
||||||
|
Register a resolver either by setting the `serializer_resolver` attribute on the plugin's `PluginConfig` (loaded from `api.serializers.serializer_resolver` by default) or by calling `netbox.plugins.register_serializer_resolver()` directly. A resolver that raises an exception, or returns a value that is not a `Serializer` subclass, is skipped and logged.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address.
|
The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.core.exceptions import (
|
from django.core.exceptions import (
|
||||||
FieldDoesNotExist,
|
FieldDoesNotExist,
|
||||||
|
|
@ -22,6 +24,8 @@ from netbox.registry import registry
|
||||||
from .query import count_related, dict_to_filter_params
|
from .query import count_related, dict_to_filter_params
|
||||||
from .string import title
|
from .string import title
|
||||||
|
|
||||||
|
logger = logging.getLogger('netbox.utilities.api')
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'IsSuperuser',
|
'IsSuperuser',
|
||||||
'get_annotations_for_serializer',
|
'get_annotations_for_serializer',
|
||||||
|
|
@ -54,9 +58,21 @@ def get_serializer_for_model(model, prefix=''):
|
||||||
wins. If no resolver matches, the default import-path lookup runs.
|
wins. If no resolver matches, the default import-path lookup runs.
|
||||||
"""
|
"""
|
||||||
for resolver in registry['serializer_resolvers']:
|
for resolver in registry['serializer_resolvers']:
|
||||||
serializer = resolver(model, prefix=prefix)
|
try:
|
||||||
if serializer is not None:
|
serializer = resolver(model, prefix=prefix)
|
||||||
return serializer
|
except Exception:
|
||||||
|
# A buggy resolver must not break serializer lookup for the rest of NetBox.
|
||||||
|
logger.exception("Serializer resolver %r raised an exception; skipping.", resolver)
|
||||||
|
continue
|
||||||
|
if serializer is None:
|
||||||
|
continue
|
||||||
|
if not (isinstance(serializer, type) and issubclass(serializer, Serializer)):
|
||||||
|
logger.warning(
|
||||||
|
"Serializer resolver %r returned %r, which is not a Serializer subclass; skipping.",
|
||||||
|
resolver, serializer,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return serializer
|
||||||
|
|
||||||
app_label, model_name = model._meta.label.split('.')
|
app_label, model_name = model._meta.label.split('.')
|
||||||
serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
|
serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,19 @@ from django.test import Client, TestCase, override_settings, tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from drf_spectacular.drainage import GENERATOR_STATS
|
from drf_spectacular.drainage import GENERATOR_STATS
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
|
from dcim.api.serializers import SiteSerializer
|
||||||
from dcim.models import Region, Site
|
from dcim.models import Region, Site
|
||||||
from extras.choices import CustomFieldTypeChoices
|
from extras.choices import CustomFieldTypeChoices
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
from netbox.api.serializers import BaseModelSerializer
|
from netbox.api.serializers import BaseModelSerializer
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.api import get_prefetches_for_serializer, get_view_name
|
from netbox.plugins import register_serializer_resolver
|
||||||
|
from netbox.registry import registry
|
||||||
|
from utilities.api import get_prefetches_for_serializer, get_serializer_for_model, get_view_name
|
||||||
from utilities.testing import APITestCase, disable_warnings
|
from utilities.testing import APITestCase, disable_warnings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -476,6 +480,14 @@ class GetPrefetchesForSerializerTestCase(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolvedSerializerA(Serializer):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolvedSerializerB(Serializer):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SerializerResolverRegistryTestCase(TestCase):
|
class SerializerResolverRegistryTestCase(TestCase):
|
||||||
"""
|
"""
|
||||||
Verify that registered serializer resolvers are consulted before the
|
Verify that registered serializer resolvers are consulted before the
|
||||||
|
|
@ -483,67 +495,44 @@ class SerializerResolverRegistryTestCase(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from netbox.registry import registry
|
|
||||||
|
|
||||||
# Snapshot and clear the resolver list so each test starts from a
|
# Snapshot and clear the resolver list so each test starts from a
|
||||||
# known state and can't leak resolvers into the rest of the suite.
|
# known state and can't leak resolvers into the rest of the suite.
|
||||||
self._saved_resolvers = list(registry['serializer_resolvers'])
|
self._saved_resolvers = list(registry['serializer_resolvers'])
|
||||||
registry['serializer_resolvers'].clear()
|
registry['serializer_resolvers'].clear()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
from netbox.registry import registry
|
|
||||||
|
|
||||||
registry['serializer_resolvers'].clear()
|
registry['serializer_resolvers'].clear()
|
||||||
registry['serializer_resolvers'].extend(self._saved_resolvers)
|
registry['serializer_resolvers'].extend(self._saved_resolvers)
|
||||||
|
|
||||||
def test_default_lookup_when_no_resolvers_registered(self):
|
def test_default_lookup_when_no_resolvers_registered(self):
|
||||||
from dcim.api.serializers import SiteSerializer
|
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
|
|
||||||
self.assertIs(get_serializer_for_model(Site), SiteSerializer)
|
self.assertIs(get_serializer_for_model(Site), SiteSerializer)
|
||||||
|
|
||||||
def test_registered_resolver_overrides_default(self):
|
def test_registered_resolver_overrides_default(self):
|
||||||
from netbox.plugins import register_serializer_resolver
|
register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerA)
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
|
|
||||||
sentinel = object()
|
self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerA)
|
||||||
register_serializer_resolver(lambda model, prefix='': sentinel)
|
|
||||||
|
|
||||||
self.assertIs(get_serializer_for_model(Site), sentinel)
|
|
||||||
|
|
||||||
def test_resolver_returning_none_falls_through_to_default(self):
|
def test_resolver_returning_none_falls_through_to_default(self):
|
||||||
from dcim.api.serializers import SiteSerializer
|
|
||||||
from netbox.plugins import register_serializer_resolver
|
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
|
|
||||||
register_serializer_resolver(lambda model, prefix='': None)
|
register_serializer_resolver(lambda model, prefix='': None)
|
||||||
|
|
||||||
self.assertIs(get_serializer_for_model(Site), SiteSerializer)
|
self.assertIs(get_serializer_for_model(Site), SiteSerializer)
|
||||||
|
|
||||||
def test_resolvers_tried_in_registration_order(self):
|
def test_resolvers_tried_in_registration_order(self):
|
||||||
from netbox.plugins import register_serializer_resolver
|
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
|
|
||||||
first = object()
|
|
||||||
second = object()
|
|
||||||
# First resolver only handles VLAN; second handles everything else.
|
# First resolver only handles VLAN; second handles everything else.
|
||||||
register_serializer_resolver(
|
register_serializer_resolver(
|
||||||
lambda model, prefix='': first if model is VLAN else None
|
lambda model, prefix='': _ResolvedSerializerA if model is VLAN else None
|
||||||
)
|
)
|
||||||
register_serializer_resolver(lambda model, prefix='': second)
|
register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerB)
|
||||||
|
|
||||||
self.assertIs(get_serializer_for_model(VLAN), first)
|
self.assertIs(get_serializer_for_model(VLAN), _ResolvedSerializerA)
|
||||||
self.assertIs(get_serializer_for_model(Site), second)
|
self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerB)
|
||||||
|
|
||||||
def test_resolver_receives_prefix(self):
|
def test_resolver_receives_prefix(self):
|
||||||
from netbox.plugins import register_serializer_resolver
|
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
|
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def resolver(model, prefix=''):
|
def resolver(model, prefix=''):
|
||||||
seen['prefix'] = prefix
|
seen['prefix'] = prefix
|
||||||
return object()
|
return _ResolvedSerializerA
|
||||||
|
|
||||||
register_serializer_resolver(resolver)
|
register_serializer_resolver(resolver)
|
||||||
get_serializer_for_model(Site, prefix='Nested')
|
get_serializer_for_model(Site, prefix='Nested')
|
||||||
|
|
@ -551,11 +540,25 @@ class SerializerResolverRegistryTestCase(TestCase):
|
||||||
self.assertEqual(seen['prefix'], 'Nested')
|
self.assertEqual(seen['prefix'], 'Nested')
|
||||||
|
|
||||||
def test_register_rejects_non_callable(self):
|
def test_register_rejects_non_callable(self):
|
||||||
from netbox.plugins import register_serializer_resolver
|
|
||||||
|
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(TypeError):
|
||||||
register_serializer_resolver('not a callable')
|
register_serializer_resolver('not a callable')
|
||||||
|
|
||||||
|
def test_raising_resolver_is_skipped(self):
|
||||||
|
def broken_resolver(model, prefix=''):
|
||||||
|
raise RuntimeError("intentional failure")
|
||||||
|
|
||||||
|
register_serializer_resolver(broken_resolver)
|
||||||
|
register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerA)
|
||||||
|
|
||||||
|
with self.assertLogs('netbox.utilities.api', level='ERROR'):
|
||||||
|
self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerA)
|
||||||
|
|
||||||
|
def test_resolver_returning_non_serializer_is_skipped(self):
|
||||||
|
register_serializer_resolver(lambda model, prefix='': object())
|
||||||
|
|
||||||
|
with self.assertLogs('netbox.utilities.api', level='WARNING'):
|
||||||
|
self.assertIs(get_serializer_for_model(Site), SiteSerializer)
|
||||||
|
|
||||||
|
|
||||||
class APITrailingSlashTestCase(APITestCase):
|
class APITrailingSlashTestCase(APITestCase):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue