Skip to content

Commit 2c35c53

Browse files
Closes #9073: Remote data support for config contexts (#11692)
* WIP * Add bulk sync view for config contexts * Introduce 'sync' permission for synced data models * Docs & cleanup * Remove unused method * Add a REST API endpoint to synchronize config context data
1 parent 1f11cd0 commit 2c35c53

File tree

20 files changed

+423
-91
lines changed

20 files changed

+423
-91
lines changed

docs/models/extras/configcontext.md

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont
1818

1919
The context data expressed in JSON format.
2020

21+
### Data File
22+
23+
Config context data may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local data for the config context: It will be populated automatically from the data file.
24+
2125
### Is Active
2226

2327
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.

docs/release-notes/version-3.5.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Enhancements
66

7+
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
78
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
89
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
910
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI

netbox/core/models/data.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import yaml
34
from fnmatch import fnmatchcase
45
from urllib.parse import urlparse
56

@@ -283,6 +284,13 @@ def data_as_string(self):
283284
except UnicodeDecodeError:
284285
return None
285286

287+
def get_data(self):
288+
"""
289+
Attempt to read the file data as JSON/YAML and return a native Python object.
290+
"""
291+
# TODO: Something more robust
292+
return yaml.safe_load(self.data_as_string)
293+
286294
def refresh_from_disk(self, source_root):
287295
"""
288296
Update instance attributes from the file on disk. Returns True if any attribute

netbox/extras/api/serializers.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from drf_yasg.utils import swagger_serializer_method
55
from rest_framework import serializers
66

7+
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
78
from dcim.api.nested_serializers import (
89
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
910
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -358,13 +359,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
358359
required=False,
359360
many=True
360361
)
362+
data_source = NestedDataSourceSerializer(
363+
required=False
364+
)
365+
data_file = NestedDataFileSerializer(
366+
read_only=True
367+
)
361368

362369
class Meta:
363370
model = ConfigContext
364371
fields = [
365372
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
366373
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
367-
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
374+
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
375+
'created', 'last_updated',
368376
]
369377

370378

netbox/extras/api/views.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from extras.reports import get_report, get_reports, run_report
1818
from extras.scripts import get_script, get_scripts, run_script
1919
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
20+
from netbox.api.features import SyncedDataMixin
2021
from netbox.api.metadata import ContentTypeMetadata
2122
from netbox.api.viewsets import NetBoxModelViewSet
2223
from utilities.exceptions import RQWorkerNotRunningException
@@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
147148
# Config contexts
148149
#
149150

150-
class ConfigContextViewSet(NetBoxModelViewSet):
151+
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
151152
queryset = ConfigContext.objects.prefetch_related(
152-
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
153+
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
154+
'data_file',
153155
)
154156
serializer_class = serializers.ConfigContextSerializer
155157
filterset_class = filtersets.ConfigContextFilterSet

netbox/extras/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
'export_templates',
99
'job_results',
1010
'journaling',
11+
'synced_data',
1112
'tags',
1213
'webhooks'
1314
]

netbox/extras/filtersets.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.db.models import Q
55
from django.utils.translation import gettext as _
66

7+
from core.models import DataFile, DataSource
78
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
89
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
910
from tenancy.models import Tenant, TenantGroup
@@ -422,10 +423,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
422423
to_field_name='slug',
423424
label=_('Tag (slug)'),
424425
)
426+
data_source_id = django_filters.ModelMultipleChoiceFilter(
427+
queryset=DataSource.objects.all(),
428+
label=_('Data source (ID)'),
429+
)
430+
data_file_id = django_filters.ModelMultipleChoiceFilter(
431+
queryset=DataSource.objects.all(),
432+
label=_('Data file (ID)'),
433+
)
425434

426435
class Meta:
427436
model = ConfigContext
428-
fields = ['id', 'name', 'is_active']
437+
fields = ['id', 'name', 'is_active', 'data_synced']
429438

430439
def search(self, queryset, name, value):
431440
if not value.strip():

netbox/extras/forms/filtersets.py

+15
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.contrib.contenttypes.models import ContentType
44
from django.utils.translation import gettext as _
55

6+
from core.models import DataFile, DataSource
67
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
78
from extras.choices import *
89
from extras.models import *
@@ -263,11 +264,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
263264
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
264265
fieldsets = (
265266
(None, ('q', 'filter_id', 'tag_id')),
267+
('Data', ('data_source_id', 'data_file_id')),
266268
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
267269
('Device', ('device_type_id', 'platform_id', 'role_id')),
268270
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
269271
('Tenant', ('tenant_group_id', 'tenant_id'))
270272
)
273+
data_source_id = DynamicModelMultipleChoiceField(
274+
queryset=DataSource.objects.all(),
275+
required=False,
276+
label=_('Data source')
277+
)
278+
data_file_id = DynamicModelMultipleChoiceField(
279+
queryset=DataFile.objects.all(),
280+
required=False,
281+
label=_('Data file'),
282+
query_params={
283+
'source_id': '$data_source_id'
284+
}
285+
)
271286
region_id = DynamicModelMultipleChoiceField(
272287
queryset=Region.objects.all(),
273288
required=False,

netbox/extras/forms/mixins.py

+19-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
from django import forms
33
from django.utils.translation import gettext as _
44

5+
from core.models import DataFile, DataSource
56
from extras.models import *
67
from extras.choices import CustomFieldVisibilityChoices
7-
from utilities.forms.fields import DynamicModelMultipleChoiceField
8+
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
89

910
__all__ = (
1011
'CustomFieldsMixin',
1112
'SavedFiltersMixin',
13+
'SyncedDataMixin',
1214
)
1315

1416

@@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
7274
'usable': True,
7375
}
7476
)
77+
78+
79+
class SyncedDataMixin(forms.Form):
80+
data_source = DynamicModelChoiceField(
81+
queryset=DataSource.objects.all(),
82+
required=False,
83+
label=_('Data source')
84+
)
85+
data_file = DynamicModelChoiceField(
86+
queryset=DataFile.objects.all(),
87+
required=False,
88+
label=_('File'),
89+
query_params={
90+
'source_id': '$data_source',
91+
}
92+
)

netbox/extras/forms/model_forms.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
77
from extras.choices import *
8+
from extras.forms.mixins import SyncedDataMixin
89
from extras.models import *
910
from extras.utils import FeatureQuery
1011
from netbox.forms import NetBoxModelForm
@@ -183,7 +184,7 @@ class Meta:
183184
]
184185

185186

186-
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
187+
class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
187188
regions = DynamicModelMultipleChoiceField(
188189
queryset=Region.objects.all(),
189190
required=False
@@ -236,10 +237,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
236237
queryset=Tag.objects.all(),
237238
required=False
238239
)
239-
data = JSONField()
240+
data = JSONField(
241+
required=False
242+
)
240243

241244
fieldsets = (
242245
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
246+
('Data Source', ('data_source', 'data_file')),
243247
('Assignment', (
244248
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
245249
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@@ -251,9 +255,17 @@ class Meta:
251255
fields = (
252256
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
253257
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
254-
'tenants', 'tags',
258+
'tenants', 'tags', 'data_source', 'data_file',
255259
)
256260

261+
def clean(self):
262+
super().clean()
263+
264+
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_source'):
265+
raise forms.ValidationError("Must specify either local data or a data source")
266+
267+
return self.cleaned_data
268+
257269

258270
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
259271

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 4.1.6 on 2023-02-06 15:34
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('core', '0001_initial'),
11+
('extras', '0084_staging'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='configcontext',
17+
name='data_file',
18+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
19+
),
20+
migrations.AddField(
21+
model_name='configcontext',
22+
name='data_path',
23+
field=models.CharField(blank=True, editable=False, max_length=1000),
24+
),
25+
migrations.AddField(
26+
model_name='configcontext',
27+
name='data_source',
28+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
29+
),
30+
migrations.AddField(
31+
model_name='configcontext',
32+
name='data_synced',
33+
field=models.DateTimeField(blank=True, editable=False, null=True),
34+
),
35+
]

netbox/extras/models/configcontexts.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
from django.core.validators import ValidationError
33
from django.db import models
44
from django.urls import reverse
5+
from django.utils import timezone
56

67
from extras.querysets import ConfigContextQuerySet
78
from netbox.models import ChangeLoggedModel
8-
from netbox.models.features import WebhooksMixin
9+
from netbox.models.features import SyncedDataMixin, WebhooksMixin
910
from utilities.utils import deepmerge
1011

1112

@@ -19,7 +20,7 @@
1920
# Config contexts
2021
#
2122

22-
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
23+
class ConfigContext(SyncedDataMixin, WebhooksMixin, ChangeLoggedModel):
2324
"""
2425
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
2526
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@@ -130,6 +131,13 @@ def clean(self):
130131
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
131132
)
132133

134+
def sync_data(self):
135+
"""
136+
Synchronize context data from the designated DataFile (if any).
137+
"""
138+
self.data = self.data_file.get_data()
139+
self.data_synced = timezone.now()
140+
133141

134142
class ConfigContextModel(models.Model):
135143
"""

netbox/extras/tables/tables.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -188,21 +188,30 @@ class Meta(NetBoxTable.Meta):
188188

189189

190190
class ConfigContextTable(NetBoxTable):
191+
data_source = tables.Column(
192+
linkify=True
193+
)
194+
data_file = tables.Column(
195+
linkify=True
196+
)
191197
name = tables.Column(
192198
linkify=True
193199
)
194200
is_active = columns.BooleanColumn(
195201
verbose_name='Active'
196202
)
203+
is_synced = columns.BooleanColumn(
204+
verbose_name='Synced'
205+
)
197206

198207
class Meta(NetBoxTable.Meta):
199208
model = ConfigContext
200209
fields = (
201-
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
202-
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
203-
'last_updated',
210+
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
211+
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
212+
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
204213
)
205-
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
214+
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
206215

207216

208217
class ObjectChangeTable(NetBoxTable):

netbox/extras/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
6161
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
6262
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
63+
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
6364
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
6465

6566
# Image attachments

netbox/extras/views.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,8 @@ class ConfigContextListView(generic.ObjectListView):
352352
filterset = filtersets.ConfigContextFilterSet
353353
filterset_form = forms.ConfigContextFilterForm
354354
table = tables.ConfigContextTable
355-
actions = ('add', 'bulk_edit', 'bulk_delete')
355+
template_name = 'extras/configcontext_list.html'
356+
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
356357

357358

358359
@register_model_view(ConfigContext)
@@ -416,6 +417,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
416417
table = tables.ConfigContextTable
417418

418419

420+
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
421+
queryset = ConfigContext.objects.all()
422+
423+
419424
class ObjectConfigContextView(generic.ObjectView):
420425
base_template = None
421426
template_name = 'extras/object_configcontext.html'

0 commit comments

Comments
 (0)