Skip to content

Commit 21d17d5

Browse files
committed
Initial work on #13381
1 parent 3f40ee5 commit 21d17d5

22 files changed

+227
-111
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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
6+
# data_backends.py
7+
from netbox.data_backends import DataBackend
8+
9+
class MyDataBackend(DataBackend):
10+
name = 'mybackend'
11+
label = 'My Backend'
12+
...
13+
```
14+
15+
To register one or more data backends with NetBox, define a list named `backends` at the end of this file:
16+
17+
```python
18+
backends = [MyDataBackend]
19+
```
20+
21+
!!! tip
22+
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
23+
24+
::: 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: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from django.core.exceptions import ValidationError
2+
from django.utils.translation import gettext_lazy as _
13
from rest_framework import serializers
24

35
from core.choices import *
46
from core.models import *
7+
from core.utils import get_data_backend_choices
58
from netbox.api.fields import ChoiceField, ContentTypeField
69
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
710
from users.api.nested_serializers import NestedUserSerializer
@@ -19,7 +22,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
1922
view_name='core-api:datasource-detail'
2023
)
2124
type = ChoiceField(
22-
choices=DataSourceTypeChoices
25+
choices=get_data_backend_choices()
2326
)
2427
status = ChoiceField(
2528
choices=DataSourceStatusChoices,
@@ -38,6 +41,13 @@ class Meta:
3841
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
3942
]
4043

44+
def clean(self):
45+
46+
if self.type and self.type not in get_data_backend_choices():
47+
raise ValidationError({
48+
'type': _("Unknown backend type: {type}".format(type=self.type))
49+
})
50+
4151

4252
class DataFileSerializer(NetBoxModelSerializer):
4353
url = serializers.HyperlinkedIdentityField(

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 core.utils import register_data_backend
14+
from netbox.data_backends import DataBackend
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
@@ -6,6 +6,7 @@
66
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
77
from .choices import *
88
from .models import *
9+
from .utils import get_data_backend_choices
910

1011
__all__ = (
1112
'DataFileFilterSet',
@@ -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 & 3 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 *
5+
from core.utils import get_data_backend_choices
66
from netbox.forms import NetBoxModelBulkEditForm
7-
from utilities.forms import add_blank_choice
87
from utilities.forms.fields import CommentField
98
from utilities.forms.widgets import BulkEditNullBooleanSelect
109

@@ -16,7 +15,8 @@
1615
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
1716
type = forms.ChoiceField(
1817
label=_('Type'),
19-
choices=add_blank_choice(DataSourceTypeChoices),
18+
# TODO: Field value should be empty on init (needs add_blank_choice())
19+
choices=get_data_backend_choices,
2020
required=False,
2121
initial=''
2222
)

netbox/core/forms/filtersets.py

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

66
from core.choices import *
77
from core.models import *
8+
from core.utils import get_data_backend_choices
89
from extras.forms.mixins import SavedFiltersMixin
910
from extras.utils import FeatureQuery
1011
from netbox.forms import NetBoxModelFilterSetForm
@@ -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
@@ -5,6 +5,7 @@
55

66
from core.forms.mixins import SyncedDataMixin
77
from core.models import *
8+
from core.utils import get_data_backend_choices
89
from netbox.forms import NetBoxModelForm
910
from netbox.registry import registry
1011
from utilities.forms import get_field_value
@@ -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: 5 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 (
@@ -124,7 +119,7 @@ def ready_for_sync(self):
124119
def clean(self):
125120

126121
# Ensure URL scheme matches selected type
127-
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
122+
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
128123
raise ValidationError({
129124
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
130125
})

netbox/core/tables/data.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ class DataSourceTable(NetBoxTable):
1515
verbose_name=_('Name'),
1616
linkify=True
1717
)
18-
type = columns.ChoiceFieldColumn(
19-
verbose_name=_('Type'),
20-
)
2118
status = columns.ChoiceFieldColumn(
2219
verbose_name=_('Status'),
2320
)
@@ -34,8 +31,8 @@ class DataSourceTable(NetBoxTable):
3431
class Meta(NetBoxTable.Meta):
3532
model = DataSource
3633
fields = (
37-
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
38-
'last_updated', 'file_count',
34+
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
35+
'created', 'last_updated', 'file_count',
3936
)
4037
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
4138

0 commit comments

Comments
 (0)