Skip to content

Commit 30ce9ed

Browse files
Closes #13381: Enable plugins to register custom data backends (#14095)
* Initial work on #13381 * Fix backend type display in table column * Fix data source type choices during bulk edit * Misc cleanup * Move backend utils from core app to netbox * Move backend type validation from serializer to model
1 parent 7274e75 commit 30ce9ed

23 files changed

+250
-113
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Data Backends
2+
3+
[Data sources](../../models/core/datasource.md) can be defined to reference data which exists on systems of record outside NetBox, such as a git repository or Amazon S3 bucket. Plugins can register their own backend classes to introduce support for additional resource types. This is done by subclassing NetBox's `DataBackend` class.
4+
5+
```python title="data_backends.py"
6+
from netbox.data_backends import DataBackend
7+
8+
class MyDataBackend(DataBackend):
9+
name = 'mybackend'
10+
label = 'My Backend'
11+
...
12+
```
13+
14+
To register one or more data backends with NetBox, define a list named `backends` at the end of this file:
15+
16+
```python title="data_backends.py"
17+
backends = [MyDataBackend]
18+
```
19+
20+
!!! tip
21+
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
22+
23+
::: core.data_backends.DataBackend

docs/plugins/development/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
109109
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
110110
| `queues` | A list of custom background task queues to create |
111111
| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
112+
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
112113
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
113114
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
114115
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ nav:
136136
- Forms: 'plugins/development/forms.md'
137137
- Filters & Filter Sets: 'plugins/development/filtersets.md'
138138
- Search: 'plugins/development/search.md'
139+
- Data Backends: 'plugins/development/data-backends.md'
139140
- REST API: 'plugins/development/rest-api.md'
140141
- GraphQL API: 'plugins/development/graphql-api.md'
141142
- Background Tasks: 'plugins/development/background-tasks.md'

netbox/core/api/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from core.models import *
55
from netbox.api.fields import ChoiceField, ContentTypeField
66
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
7+
from netbox.utils import get_data_backend_choices
78
from users.api.nested_serializers import NestedUserSerializer
89
from .nested_serializers import *
910

@@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
1920
view_name='core-api:datasource-detail'
2021
)
2122
type = ChoiceField(
22-
choices=DataSourceTypeChoices
23+
choices=get_data_backend_choices()
2324
)
2425
status = ChoiceField(
2526
choices=DataSourceStatusChoices,

netbox/core/choices.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,6 @@
77
# Data sources
88
#
99

10-
class DataSourceTypeChoices(ChoiceSet):
11-
LOCAL = 'local'
12-
GIT = 'git'
13-
AMAZON_S3 = 'amazon-s3'
14-
15-
CHOICES = (
16-
(LOCAL, _('Local'), 'gray'),
17-
(GIT, 'Git', 'blue'),
18-
(AMAZON_S3, 'Amazon S3', 'blue'),
19-
)
20-
21-
2210
class DataSourceStatusChoices(ChoiceSet):
2311
NEW = 'new'
2412
QUEUED = 'queued'

netbox/core/data_backends.py

Lines changed: 13 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,61 +10,24 @@
1010
from django.conf import settings
1111
from django.utils.translation import gettext as _
1212

13-
from netbox.registry import registry
14-
from .choices import DataSourceTypeChoices
13+
from netbox.data_backends import DataBackend
14+
from netbox.utils import register_data_backend
1515
from .exceptions import SyncError
1616

1717
__all__ = (
18-
'LocalBackend',
1918
'GitBackend',
19+
'LocalBackend',
2020
'S3Backend',
2121
)
2222

2323
logger = logging.getLogger('netbox.data_backends')
2424

2525

26-
def register_backend(name):
27-
"""
28-
Decorator for registering a DataBackend class.
29-
"""
30-
31-
def _wrapper(cls):
32-
registry['data_backends'][name] = cls
33-
return cls
34-
35-
return _wrapper
36-
37-
38-
class DataBackend:
39-
parameters = {}
40-
sensitive_parameters = []
41-
42-
# Prevent Django's template engine from calling the backend
43-
# class when referenced via DataSource.backend_class
44-
do_not_call_in_templates = True
45-
46-
def __init__(self, url, **kwargs):
47-
self.url = url
48-
self.params = kwargs
49-
self.config = self.init_config()
50-
51-
def init_config(self):
52-
"""
53-
Hook to initialize the instance's configuration.
54-
"""
55-
return
56-
57-
@property
58-
def url_scheme(self):
59-
return urlparse(self.url).scheme.lower()
60-
61-
@contextmanager
62-
def fetch(self):
63-
raise NotImplemented()
64-
65-
66-
@register_backend(DataSourceTypeChoices.LOCAL)
26+
@register_data_backend()
6727
class LocalBackend(DataBackend):
28+
name = 'local'
29+
label = _('Local')
30+
is_local = True
6831

6932
@contextmanager
7033
def fetch(self):
@@ -74,8 +37,10 @@ def fetch(self):
7437
yield local_path
7538

7639

77-
@register_backend(DataSourceTypeChoices.GIT)
40+
@register_data_backend()
7841
class GitBackend(DataBackend):
42+
name = 'git'
43+
label = 'Git'
7944
parameters = {
8045
'username': forms.CharField(
8146
required=False,
@@ -144,8 +109,10 @@ def fetch(self):
144109
local_path.cleanup()
145110

146111

147-
@register_backend(DataSourceTypeChoices.AMAZON_S3)
112+
@register_data_backend()
148113
class S3Backend(DataBackend):
114+
name = 'amazon-s3'
115+
label = 'Amazon S3'
149116
parameters = {
150117
'aws_access_key_id': forms.CharField(
151118
label=_('AWS access key ID'),

netbox/core/filtersets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import django_filters
55

66
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
7+
from netbox.utils import get_data_backend_choices
78
from .choices import *
89
from .models import *
910

@@ -16,7 +17,7 @@
1617

1718
class DataSourceFilterSet(NetBoxModelFilterSet):
1819
type = django_filters.MultipleChoiceFilter(
19-
choices=DataSourceTypeChoices,
20+
choices=get_data_backend_choices,
2021
null_value=None
2122
)
2223
status = django_filters.MultipleChoiceFilter(

netbox/core/forms/bulk_edit.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from django import forms
22
from django.utils.translation import gettext_lazy as _
33

4-
from core.choices import DataSourceTypeChoices
54
from core.models import *
65
from netbox.forms import NetBoxModelBulkEditForm
7-
from utilities.forms import add_blank_choice
6+
from netbox.utils import get_data_backend_choices
87
from utilities.forms.fields import CommentField
98
from utilities.forms.widgets import BulkEditNullBooleanSelect
109

@@ -16,9 +15,8 @@
1615
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
1716
type = forms.ChoiceField(
1817
label=_('Type'),
19-
choices=add_blank_choice(DataSourceTypeChoices),
20-
required=False,
21-
initial=''
18+
choices=get_data_backend_choices,
19+
required=False
2220
)
2321
enabled = forms.NullBooleanField(
2422
required=False,

netbox/core/forms/filtersets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from extras.forms.mixins import SavedFiltersMixin
99
from extras.utils import FeatureQuery
1010
from netbox.forms import NetBoxModelFilterSetForm
11+
from netbox.utils import get_data_backend_choices
1112
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
1213
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
1314
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
@@ -27,7 +28,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
2728
)
2829
type = forms.MultipleChoiceField(
2930
label=_('Type'),
30-
choices=DataSourceTypeChoices,
31+
choices=get_data_backend_choices,
3132
required=False
3233
)
3334
status = forms.MultipleChoiceField(

netbox/core/forms/model_forms.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from core.models import *
88
from netbox.forms import NetBoxModelForm
99
from netbox.registry import registry
10+
from netbox.utils import get_data_backend_choices
1011
from utilities.forms import get_field_value
1112
from utilities.forms.fields import CommentField
1213
from utilities.forms.widgets import HTMXSelect
@@ -18,6 +19,10 @@
1819

1920

2021
class DataSourceForm(NetBoxModelForm):
22+
type = forms.ChoiceField(
23+
choices=get_data_backend_choices,
24+
widget=HTMXSelect()
25+
)
2126
comments = CommentField()
2227

2328
class Meta:
@@ -26,7 +31,6 @@ class Meta:
2631
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
2732
]
2833
widgets = {
29-
'type': HTMXSelect(),
3034
'ignore_rules': forms.Textarea(
3135
attrs={
3236
'rows': 5,
@@ -56,12 +60,13 @@ def __init__(self, *args, **kwargs):
5660

5761
# Add backend-specific form fields
5862
self.backend_fields = []
59-
for name, form_field in backend.parameters.items():
60-
field_name = f'backend_{name}'
61-
self.backend_fields.append(field_name)
62-
self.fields[field_name] = copy.copy(form_field)
63-
if self.instance and self.instance.parameters:
64-
self.fields[field_name].initial = self.instance.parameters.get(name)
63+
if backend:
64+
for name, form_field in backend.parameters.items():
65+
field_name = f'backend_{name}'
66+
self.backend_fields.append(field_name)
67+
self.fields[field_name] = copy.copy(form_field)
68+
if self.instance and self.instance.parameters:
69+
self.fields[field_name].initial = self.instance.parameters.get(name)
6570

6671
def save(self, *args, **kwargs):
6772

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.6 on 2023-10-20 17:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0005_job_created_auto_now'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='datasource',
15+
name='type',
16+
field=models.CharField(max_length=50),
17+
),
18+
]

netbox/core/models/data.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,7 @@ class DataSource(JobsMixin, PrimaryModel):
4545
)
4646
type = models.CharField(
4747
verbose_name=_('type'),
48-
max_length=50,
49-
choices=DataSourceTypeChoices,
50-
default=DataSourceTypeChoices.LOCAL
48+
max_length=50
5149
)
5250
source_url = models.CharField(
5351
max_length=200,
@@ -96,8 +94,9 @@ def get_absolute_url(self):
9694
def docs_url(self):
9795
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
9896

99-
def get_type_color(self):
100-
return DataSourceTypeChoices.colors.get(self.type)
97+
def get_type_display(self):
98+
if backend := registry['data_backends'].get(self.type):
99+
return backend.label
101100

102101
def get_status_color(self):
103102
return DataSourceStatusChoices.colors.get(self.status)
@@ -110,10 +109,6 @@ def url_scheme(self):
110109
def backend_class(self):
111110
return registry['data_backends'].get(self.type)
112111

113-
@property
114-
def is_local(self):
115-
return self.type == DataSourceTypeChoices.LOCAL
116-
117112
@property
118113
def ready_for_sync(self):
119114
return self.enabled and self.status not in (
@@ -123,8 +118,14 @@ def ready_for_sync(self):
123118

124119
def clean(self):
125120

121+
# Validate data backend type
122+
if self.type and self.type not in registry['data_backends']:
123+
raise ValidationError({
124+
'type': _("Unknown backend type: {type}".format(type=self.type))
125+
})
126+
126127
# Ensure URL scheme matches selected type
127-
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
128+
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
128129
raise ValidationError({
129130
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
130131
})

netbox/core/tables/columns.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import django_tables2 as tables
2+
3+
from netbox.registry import registry
4+
5+
__all__ = (
6+
'BackendTypeColumn',
7+
)
8+
9+
10+
class BackendTypeColumn(tables.Column):
11+
"""
12+
Display a data backend type.
13+
"""
14+
def render(self, value):
15+
if backend := registry['data_backends'].get(value):
16+
return backend.label
17+
return value
18+
19+
def value(self, value):
20+
return value

netbox/core/tables/data.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from core.models import *
55
from netbox.tables import NetBoxTable, columns
6+
from .columns import BackendTypeColumn
67

78
__all__ = (
89
'DataFileTable',
@@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
1516
verbose_name=_('Name'),
1617
linkify=True
1718
)
18-
type = columns.ChoiceFieldColumn(
19-
verbose_name=_('Type'),
19+
type = BackendTypeColumn(
20+
verbose_name=_('Type')
2021
)
2122
status = columns.ChoiceFieldColumn(
2223
verbose_name=_('Status'),
@@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
3435
class Meta(NetBoxTable.Meta):
3536
model = DataSource
3637
fields = (
37-
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
38-
'last_updated', 'file_count',
38+
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
39+
'created', 'last_updated', 'file_count',
3940
)
4041
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
4142

0 commit comments

Comments
 (0)