Merge pull request #8562 from netbox-community/8405-plugins-graphql

Closes #8405: GraphQL support for plugins
This commit is contained in:
Jeremy Stretch 2022-02-07 13:08:32 -05:00 committed by GitHub
commit 0e827b6ae6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 201 additions and 87 deletions

View File

@ -0,0 +1,59 @@
# GraphQL API
## Defining the Schema Class
A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`.
### Example
```python
# graphql.py
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from . import filtersets, models
class MyModelType(graphene.ObjectType):
class Meta:
model = models.MyModel
fields = '__all__'
filterset_class = filtersets.MyModelFilterSet
class MyQuery(graphene.ObjectType):
mymodel = ObjectField(MyModelType)
mymodel_list = ObjectListField(MyModelType)
schema = MyQuery
```
## GraphQL Objects
NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.BaseObjectType
selection:
members: false
rendering:
show_source: false
::: netbox.graphql.types.NetBoxObjectType
selection:
members: false
rendering:
show_source: false
## GraphQL Fields
NetBox provides two field classes for use by plugins.
::: netbox.graphql.fields.ObjectField
selection:
members: false
rendering:
show_source: false
::: netbox.graphql.fields.ObjectListField
selection:
members: false
rendering:
show_source: false

View File

@ -22,7 +22,7 @@ However, keep in mind that each piece of functionality is entirely optional. For
### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look something like this:
```no-highlight
project-name/
@ -102,23 +102,24 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
#### PluginConfig Attributes
| Name | Description |
| ---- |---------------------------------------------------------------------------------------------------------------|
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values |
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
| Name | Description |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------|
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values |
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `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`) |
| `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

@ -108,6 +108,7 @@ nav:
- Forms: 'plugins/development/forms.md'
- Filter Sets: 'plugins/development/filtersets.md'
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Administration:
- Authentication: 'administration/authentication.md'

View File

@ -1,5 +1,5 @@
from circuits import filtersets, models
from netbox.graphql.types import ObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
'CircuitTerminationType',
@ -18,7 +18,7 @@ class CircuitTerminationType(ObjectType):
filterset_class = filtersets.CircuitTerminationFilterSet
class CircuitType(PrimaryObjectType):
class CircuitType(NetBoxObjectType):
class Meta:
model = models.Circuit
@ -34,7 +34,7 @@ class CircuitTypeType(OrganizationalObjectType):
filterset_class = filtersets.CircuitTypeFilterSet
class ProviderType(PrimaryObjectType):
class ProviderType(NetBoxObjectType):
class Meta:
model = models.Provider
@ -42,7 +42,7 @@ class ProviderType(PrimaryObjectType):
filterset_class = filtersets.ProviderFilterSet
class ProviderNetworkType(PrimaryObjectType):
class ProviderNetworkType(NetBoxObjectType):
class Meta:
model = models.ProviderNetwork

View File

@ -6,7 +6,7 @@ from extras.graphql.mixins import (
)
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
'CableType',
@ -85,7 +85,7 @@ class ComponentTemplateObjectType(
# Model types
#
class CableType(PrimaryObjectType):
class CableType(NetBoxObjectType):
class Meta:
model = models.Cable
@ -143,7 +143,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType):
return self.type or None
class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType):
class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType):
class Meta:
model = models.Device
@ -189,7 +189,7 @@ class DeviceRoleType(OrganizationalObjectType):
filterset_class = filtersets.DeviceRoleFilterSet
class DeviceTypeType(PrimaryObjectType):
class DeviceTypeType(NetBoxObjectType):
class Meta:
model = models.DeviceType
@ -300,7 +300,7 @@ class ModuleBayTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.ModuleBayTemplateFilterSet
class ModuleTypeType(PrimaryObjectType):
class ModuleTypeType(NetBoxObjectType):
class Meta:
model = models.ModuleType
@ -316,7 +316,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(PrimaryObjectType):
class PowerFeedType(NetBoxObjectType):
class Meta:
model = models.PowerFeed
@ -352,7 +352,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType):
return self.type or None
class PowerPanelType(PrimaryObjectType):
class PowerPanelType(NetBoxObjectType):
class Meta:
model = models.PowerPanel
@ -382,7 +382,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType):
return self.type or None
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
class Meta:
model = models.Rack
@ -396,7 +396,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
return self.outer_unit or None
class RackReservationType(PrimaryObjectType):
class RackReservationType(NetBoxObjectType):
class Meta:
model = models.RackReservation
@ -436,7 +436,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
filterset_class = filtersets.RegionFilterSet
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
asn = graphene.Field(BigInt)
class Meta:
@ -453,7 +453,7 @@ class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType):
filterset_class = filtersets.SiteGroupFilterSet
class VirtualChassisType(PrimaryObjectType):
class VirtualChassisType(NetBoxObjectType):
class Meta:
model = models.VirtualChassis

View File

@ -12,10 +12,13 @@ from utilities.choices import ButtonColorChoices
from extras.plugins.utils import import_object
# Initialize plugin registry stores
registry['plugin_template_extensions'] = collections.defaultdict(list)
registry['plugin_menu_items'] = {}
registry['plugin_preferences'] = {}
# Initialize plugin registry
registry['plugins'] = {
'graphql_schemas': [],
'menu_items': {},
'preferences': {},
'template_extensions': collections.defaultdict(list),
}
#
@ -53,13 +56,15 @@ class PluginConfig(AppConfig):
# Default integration paths. Plugin authors can override these to customize the paths to
# integrated components.
template_extensions = 'template_content.template_extensions'
graphql_schema = 'graphql.schema'
menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences'
def ready(self):
plugin_name = self.name.rsplit('.', 1)[1]
# Register template content
# Register template content (if defined)
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
if template_extensions is not None:
register_template_extensions(template_extensions)
@ -69,10 +74,14 @@ class PluginConfig(AppConfig):
if menu_items is not None:
register_menu_items(self.verbose_name, menu_items)
# Register user preferences
# Register GraphQL schema (if defined)
graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}")
if graphql_schema is not None:
register_graphql_schema(graphql_schema)
# Register user preferences (if defined)
user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
if user_preferences is not None:
plugin_name = self.name.rsplit('.', 1)[1]
register_user_preferences(plugin_name, user_preferences)
@classmethod
@ -178,13 +187,13 @@ def register_template_extensions(class_list):
# Validation
for template_extension in class_list:
if not inspect.isclass(template_extension):
raise TypeError(f"PluginTemplateExtension class {template_extension} was passes as an instance!")
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
if not issubclass(template_extension, PluginTemplateExtension):
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
if template_extension.model is None:
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
registry['plugin_template_extensions'][template_extension.model].append(template_extension)
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
#
@ -249,7 +258,18 @@ def register_menu_items(section_name, class_list):
if not isinstance(button, PluginMenuButton):
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
registry['plugin_menu_items'][section_name] = class_list
registry['plugins']['menu_items'][section_name] = class_list
#
# GraphQL schemas
#
def register_graphql_schema(graphql_schema):
"""
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
"""
registry['plugins']['graphql_schemas'].append(graphql_schema)
#
@ -260,4 +280,4 @@ def register_user_preferences(plugin_name, preferences):
"""
Register a list of user preferences defined by a plugin.
"""
registry['plugin_preferences'][plugin_name] = preferences
registry['plugins']['preferences'][plugin_name] = preferences

View File

@ -23,7 +23,7 @@ def _get_registered_content(obj, method, template_context):
}
model_name = obj._meta.label_lower
template_extensions = registry['plugin_template_extensions'].get(model_name, [])
template_extensions = registry['plugins']['template_extensions'].get(model_name, [])
for template_extension in template_extensions:
# If the class has not overridden the specified method, we can skip it (because we know it

View File

@ -0,0 +1,21 @@
import graphene
from graphene_django import DjangoObjectType
from netbox.graphql.fields import ObjectField, ObjectListField
from . import models
class DummyModelType(DjangoObjectType):
class Meta:
model = models.DummyModel
fields = '__all__'
class DummyQuery(graphene.ObjectType):
dummymodel = ObjectField(DummyModelType)
dummymodel_list = ObjectListField(DummyModelType)
schema = DummyQuery

View File

@ -7,6 +7,7 @@ from django.urls import reverse
from extras.registry import registry
from extras.tests.dummy_plugin import config as dummy_config
from netbox.graphql.schema import Query
@skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
@ -61,8 +62,8 @@ class PluginTest(TestCase):
"""
Check that plugin MenuItems and MenuButtons are registered.
"""
self.assertIn('Dummy plugin', registry['plugin_menu_items'])
menu_items = registry['plugin_menu_items']['Dummy plugin']
self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
menu_items = registry['plugins']['menu_items']['Dummy plugin']
self.assertEqual(len(menu_items), 2)
self.assertEqual(len(menu_items[0].buttons), 2)
@ -72,14 +73,14 @@ class PluginTest(TestCase):
"""
from extras.tests.dummy_plugin.template_content import SiteContent
self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site'])
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
def test_user_preferences(self):
"""
Check that plugin UserPreferences are registered.
"""
self.assertIn('dummy_plugin', registry['plugin_preferences'])
user_preferences = registry['plugin_preferences']['dummy_plugin']
self.assertIn('dummy_plugin', registry['plugins']['preferences'])
user_preferences = registry['plugins']['preferences']['dummy_plugin']
self.assertEqual(type(user_preferences), dict)
self.assertEqual(list(user_preferences.keys()), ['pref1', 'pref2'])
@ -143,3 +144,12 @@ class PluginTest(TestCase):
user_config = {'bar': 456}
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 456)
def test_graphql(self):
"""
Validate the registration and operation of plugin-provided GraphQL schemas.
"""
from extras.tests.dummy_plugin.graphql import DummyQuery
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
self.assertTrue(issubclass(Query, DummyQuery))

View File

@ -2,7 +2,7 @@ import graphene
from ipam import filtersets, models
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
'ASNType',
@ -23,7 +23,7 @@ __all__ = (
)
class ASNType(PrimaryObjectType):
class ASNType(NetBoxObjectType):
asn = graphene.Field(BigInt)
class Meta:
@ -32,7 +32,7 @@ class ASNType(PrimaryObjectType):
filterset_class = filtersets.ASNFilterSet
class AggregateType(PrimaryObjectType):
class AggregateType(NetBoxObjectType):
class Meta:
model = models.Aggregate
@ -40,7 +40,7 @@ class AggregateType(PrimaryObjectType):
filterset_class = filtersets.AggregateFilterSet
class FHRPGroupType(PrimaryObjectType):
class FHRPGroupType(NetBoxObjectType):
class Meta:
model = models.FHRPGroup
@ -59,7 +59,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
class IPAddressType(PrimaryObjectType):
class IPAddressType(NetBoxObjectType):
class Meta:
model = models.IPAddress
@ -70,7 +70,7 @@ class IPAddressType(PrimaryObjectType):
return self.role or None
class IPRangeType(PrimaryObjectType):
class IPRangeType(NetBoxObjectType):
class Meta:
model = models.IPRange
@ -81,7 +81,7 @@ class IPRangeType(PrimaryObjectType):
return self.role or None
class PrefixType(PrimaryObjectType):
class PrefixType(NetBoxObjectType):
class Meta:
model = models.Prefix
@ -105,7 +105,7 @@ class RoleType(OrganizationalObjectType):
filterset_class = filtersets.RoleFilterSet
class RouteTargetType(PrimaryObjectType):
class RouteTargetType(NetBoxObjectType):
class Meta:
model = models.RouteTarget
@ -113,7 +113,7 @@ class RouteTargetType(PrimaryObjectType):
filterset_class = filtersets.RouteTargetFilterSet
class ServiceType(PrimaryObjectType):
class ServiceType(NetBoxObjectType):
class Meta:
model = models.Service
@ -121,7 +121,7 @@ class ServiceType(PrimaryObjectType):
filterset_class = filtersets.ServiceFilterSet
class ServiceTemplateType(PrimaryObjectType):
class ServiceTemplateType(NetBoxObjectType):
class Meta:
model = models.ServiceTemplate
@ -129,7 +129,7 @@ class ServiceTemplateType(PrimaryObjectType):
filterset_class = filtersets.ServiceTemplateFilterSet
class VLANType(PrimaryObjectType):
class VLANType(NetBoxObjectType):
class Meta:
model = models.VLAN
@ -145,7 +145,7 @@ class VLANGroupType(OrganizationalObjectType):
filterset_class = filtersets.VLANGroupFilterSet
class VRFType(PrimaryObjectType):
class VRFType(NetBoxObjectType):
class Meta:
model = models.VRF

View File

@ -41,15 +41,14 @@ class ObjectListField(DjangoListField):
Retrieve a list of objects, optionally filtered by one or more FilterSet filters.
"""
def __init__(self, _type, *args, **kwargs):
assert hasattr(_type._meta, 'filterset_class'), "DjangoFilterListField must define filterset_class under Meta"
filterset_class = _type._meta.filterset_class
filter_kwargs = {}
# Get FilterSet kwargs
filter_kwargs = {}
for filter_name, filter_field in filterset_class.get_filters().items():
field_type = get_graphene_type(type(filter_field))
filter_kwargs[filter_name] = graphene.Argument(field_type)
filterset_class = getattr(_type._meta, 'filterset_class', None)
if filterset_class:
for filter_name, filter_field in filterset_class.get_filters().items():
field_type = get_graphene_type(type(filter_field))
filter_kwargs[filter_name] = graphene.Argument(field_type)
super().__init__(_type, args=filter_kwargs, *args, **kwargs)

View File

@ -3,6 +3,7 @@ import graphene
from circuits.graphql.schema import CircuitsQuery
from dcim.graphql.schema import DCIMQuery
from extras.graphql.schema import ExtrasQuery
from extras.registry import registry
from ipam.graphql.schema import IPAMQuery
from tenancy.graphql.schema import TenancyQuery
from users.graphql.schema import UsersQuery
@ -19,6 +20,7 @@ class Query(
UsersQuery,
VirtualizationQuery,
WirelessQuery,
*registry['plugins']['graphql_schemas'], # Append plugin schemas
graphene.ObjectType
):
pass

View File

@ -5,8 +5,9 @@ from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, JournalEntr
__all__ = (
'BaseObjectType',
'ObjectType',
'OrganizationalObjectType',
'PrimaryObjectType',
'NetBoxObjectType',
)
@ -16,7 +17,7 @@ __all__ = (
class BaseObjectType(DjangoObjectType):
"""
Base GraphQL object type for all NetBox objects
Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions.
"""
class Meta:
abstract = True
@ -51,7 +52,7 @@ class OrganizationalObjectType(
abstract = True
class PrimaryObjectType(
class NetBoxObjectType(
ChangelogMixin,
CustomFieldsMixin,
JournalEntriesMixin,
@ -59,7 +60,7 @@ class PrimaryObjectType(
BaseObjectType
):
"""
Base type for primary models
GraphQL type for most NetBox models. Includes support for custom fields, change logging, journaling, and tags.
"""
class Meta:
abstract = True

View File

@ -390,10 +390,10 @@ MENUS = [
# Add plugin menus
#
if registry['plugin_menu_items']:
if registry['plugins']['menu_items']:
plugin_menu_groups = []
for plugin_name, items in registry['plugin_menu_items'].items():
for plugin_name, items in registry['plugins']['menu_items'].items():
plugin_menu_groups.append(
MenuGroup(
label=plugin_name,

View File

@ -49,10 +49,10 @@ PREFERENCES = {
}
# Register plugin preferences
if registry['plugin_preferences']:
if registry['plugins']['preferences']:
plugin_preferences = {}
for plugin_name, preferences in registry['plugin_preferences'].items():
for plugin_name, preferences in registry['plugins']['preferences'].items():
for name, userpreference in preferences.items():
PREFERENCES[f'plugins.{plugin_name}.{name}'] = userpreference

View File

@ -1,7 +1,7 @@
import graphene
from tenancy import filtersets, models
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
'ContactAssignmentType',
@ -24,7 +24,7 @@ class ContactAssignmentsMixin:
# Tenants
#
class TenantType(PrimaryObjectType):
class TenantType(NetBoxObjectType):
class Meta:
model = models.Tenant
@ -44,7 +44,7 @@ class TenantGroupType(OrganizationalObjectType):
# Contacts
#
class ContactType(ContactAssignmentsMixin, PrimaryObjectType):
class ContactType(ContactAssignmentsMixin, NetBoxObjectType):
class Meta:
model = models.Contact

View File

@ -1,7 +1,7 @@
from dcim.graphql.types import ComponentObjectType
from extras.graphql.mixins import ConfigContextMixin
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
from virtualization import filtersets, models
__all__ = (
@ -13,7 +13,7 @@ __all__ = (
)
class ClusterType(VLANGroupsMixin, PrimaryObjectType):
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
class Meta:
model = models.Cluster
@ -37,7 +37,7 @@ class ClusterTypeType(OrganizationalObjectType):
filterset_class = filtersets.ClusterTypeFilterSet
class VirtualMachineType(ConfigContextMixin, PrimaryObjectType):
class VirtualMachineType(ConfigContextMixin, NetBoxObjectType):
class Meta:
model = models.VirtualMachine

View File

@ -1,5 +1,5 @@
from wireless import filtersets, models
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
__all__ = (
'WirelessLANType',
@ -16,7 +16,7 @@ class WirelessLANGroupType(OrganizationalObjectType):
filterset_class = filtersets.WirelessLANGroupFilterSet
class WirelessLANType(PrimaryObjectType):
class WirelessLANType(NetBoxObjectType):
class Meta:
model = models.WirelessLAN
@ -30,7 +30,7 @@ class WirelessLANType(PrimaryObjectType):
return self.auth_cipher or None
class WirelessLinkType(PrimaryObjectType):
class WirelessLinkType(NetBoxObjectType):
class Meta:
model = models.WirelessLink