Skip to content

Commit 9862888

Browse files
committed
Closes netbox-community#11693: Enable remote data synchronization for export templates
1 parent 2c35c53 commit 9862888

File tree

18 files changed

+245
-88
lines changed

18 files changed

+245
-88
lines changed

docs/models/extras/exporttemplate.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ The name of the export template. This will appear in the "export" dropdown list
1212

1313
The type of NetBox object to which the export template applies.
1414

15+
### Data File
16+
17+
Template code 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 content for the template: It will be populated automatically from the data file.
18+
1519
### Template Code
1620

1721
Jinja2 template code for rendering the exported data.

netbox/extras/api/serializers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,19 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
142142
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
143143
many=True
144144
)
145+
data_source = NestedDataSourceSerializer(
146+
required=False
147+
)
148+
data_file = NestedDataFileSerializer(
149+
read_only=True
150+
)
145151

146152
class Meta:
147153
model = ExportTemplate
148154
fields = [
149155
'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
150-
'file_extension', 'as_attachment', 'created', 'last_updated',
156+
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
157+
'last_updated',
151158
]
152159

153160

netbox/extras/api/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ class CustomLinkViewSet(NetBoxModelViewSet):
9292
# Export templates
9393
#
9494

95-
class ExportTemplateViewSet(NetBoxModelViewSet):
95+
class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
9696
metadata_class = ContentTypeMetadata
97-
queryset = ExportTemplate.objects.all()
97+
queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file')
9898
serializer_class = serializers.ExportTemplateSerializer
9999
filterset_class = filtersets.ExportTemplateFilterSet
100100

netbox/extras/filtersets.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,18 @@ class ExportTemplateFilterSet(BaseFilterSet):
127127
field_name='content_types__id'
128128
)
129129
content_types = ContentTypeFilter()
130+
data_source_id = django_filters.ModelMultipleChoiceFilter(
131+
queryset=DataSource.objects.all(),
132+
label=_('Data source (ID)'),
133+
)
134+
data_file_id = django_filters.ModelMultipleChoiceFilter(
135+
queryset=DataSource.objects.all(),
136+
label=_('Data file (ID)'),
137+
)
130138

131139
class Meta:
132140
model = ExportTemplate
133-
fields = ['id', 'content_types', 'name', 'description']
141+
fields = ['id', 'content_types', 'name', 'description', 'data_synced']
134142

135143
def search(self, queryset, name, value):
136144
if not value.strip():

netbox/extras/forms/filtersets.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
__all__ = (
2222
'ConfigContextFilterForm',
2323
'CustomFieldFilterForm',
24-
'JobResultFilterForm',
2524
'CustomLinkFilterForm',
2625
'ExportTemplateFilterForm',
26+
'JobResultFilterForm',
2727
'JournalEntryFilterForm',
2828
'LocalConfigContextFilterForm',
2929
'ObjectChangeFilterForm',
@@ -160,8 +160,22 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
160160
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
161161
fieldsets = (
162162
(None, ('q', 'filter_id')),
163+
('Data', ('data_source_id', 'data_file_id')),
163164
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
164165
)
166+
data_source_id = DynamicModelMultipleChoiceField(
167+
queryset=DataSource.objects.all(),
168+
required=False,
169+
label=_('Data source')
170+
)
171+
data_file_id = DynamicModelMultipleChoiceField(
172+
queryset=DataFile.objects.all(),
173+
required=False,
174+
label=_('Data file'),
175+
query_params={
176+
'source_id': '$data_source_id'
177+
}
178+
)
165179
content_types = ContentTypeMultipleChoiceField(
166180
queryset=ContentType.objects.all(),
167181
limit_choices_to=FeatureQuery('export_templates'),

netbox/extras/forms/model_forms.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,28 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
9696
queryset=ContentType.objects.all(),
9797
limit_choices_to=FeatureQuery('export_templates')
9898
)
99+
template_code = forms.CharField(
100+
required=False,
101+
widget=forms.Textarea(attrs={'class': 'font-monospace'})
102+
)
99103

100104
fieldsets = (
101105
('Export Template', ('name', 'content_types', 'description')),
102-
('Template', ('template_code',)),
106+
('Content', ('data_source', 'data_file', 'template_code',)),
103107
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
104108
)
105109

106110
class Meta:
107111
model = ExportTemplate
108112
fields = '__all__'
109-
widgets = {
110-
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
111-
}
113+
114+
def clean(self):
115+
super().clean()
116+
117+
if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
118+
raise forms.ValidationError("Must specify either local content or a data file")
119+
120+
return self.cleaned_data
112121

113122

114123
class SavedFilterForm(BootstrapMixin, forms.ModelForm):
@@ -261,8 +270,8 @@ class Meta:
261270
def clean(self):
262271
super().clean()
263272

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")
273+
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
274+
raise forms.ValidationError("Must specify either local data or a data file")
266275

267276
return self.cleaned_data
268277

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 4.1.6 on 2023-02-08 22:16
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', '0085_configcontext_synced_data'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='exporttemplate',
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='exporttemplate',
22+
name='data_path',
23+
field=models.CharField(blank=True, editable=False, max_length=1000),
24+
),
25+
migrations.AddField(
26+
model_name='exporttemplate',
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='exporttemplate',
32+
name='data_synced',
33+
field=models.DateTimeField(blank=True, editable=False, null=True),
34+
),
35+
]

netbox/extras/models/models.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
from netbox.constants import RQ_QUEUE_DEFAULT
2727
from netbox.models import ChangeLoggedModel
2828
from netbox.models.features import (
29-
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
29+
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
30+
TagsMixin, WebhooksMixin,
3031
)
3132
from utilities.querysets import RestrictedQuerySet
3233
from utilities.utils import render_jinja2
@@ -281,7 +282,7 @@ def render(self, context):
281282
}
282283

283284

284-
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
285+
class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
285286
content_types = models.ManyToManyField(
286287
to=ContentType,
287288
related_name='export_templates',
@@ -335,6 +336,13 @@ def clean(self):
335336
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
336337
})
337338

339+
def sync_data(self):
340+
"""
341+
Synchronize template content from the designated DataFile (if any).
342+
"""
343+
self.template_code = self.data_file.data_as_string
344+
self.data_synced = timezone.now()
345+
338346
def render(self, queryset):
339347
"""
340348
Render the contents of the template.

netbox/extras/tables/tables.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,24 @@ class ExportTemplateTable(NetBoxTable):
9090
)
9191
content_types = columns.ContentTypesColumn()
9292
as_attachment = columns.BooleanColumn()
93+
data_source = tables.Column(
94+
linkify=True
95+
)
96+
data_file = tables.Column(
97+
linkify=True
98+
)
99+
is_synced = columns.BooleanColumn(
100+
verbose_name='Synced'
101+
)
93102

94103
class Meta(NetBoxTable.Meta):
95104
model = ExportTemplate
96105
fields = (
97106
'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
98-
'created', 'last_updated',
107+
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
99108
)
100109
default_columns = (
101-
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
110+
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
102111
)
103112

104113

netbox/extras/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
3030
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
3131
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
32+
path('export-templates/sync/', views.ExportTemplateBulkSyncDataView.as_view(), name='exporttemplate_bulk_sync'),
3233
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
3334

3435
# Saved filters

netbox/extras/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ class ExportTemplateListView(generic.ObjectListView):
121121
filterset = filtersets.ExportTemplateFilterSet
122122
filterset_form = forms.ExportTemplateFilterForm
123123
table = tables.ExportTemplateTable
124+
template_name = 'extras/exporttemplate_list.html'
125+
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
124126

125127

126128
@register_model_view(ExportTemplate)
@@ -158,6 +160,10 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
158160
table = tables.ExportTemplateTable
159161

160162

163+
class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
164+
queryset = ExportTemplate.objects.all()
165+
166+
161167
#
162168
# Saved filters
163169
#

netbox/templates/extras/configcontext.html

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ <h5 class="card-header">Config Context</h5>
5050
{% endif %}
5151
</td>
5252
</tr>
53-
<tr>
54-
<th scope="row">Data Synced</th>
55-
<td>{{ object.data_synced|placeholder }}</td>
56-
</tr>
53+
<tr>
54+
<th scope="row">Data Synced</th>
55+
<td>{{ object.data_synced|placeholder }}</td>
56+
</tr>
5757
</table>
5858
</div>
5959
</div>
@@ -86,22 +86,8 @@ <h5>Data</h5>
8686
{% include 'extras/inc/configcontext_format.html' %}
8787
</div>
8888
<div class="card-body">
89-
{% if object.data_file and object.data_file.last_updated > object.data_synced %}
90-
<div class="alert alert-warning" role="alert">
91-
<i class="mdi mdi-alert"></i> Data is out of sync with upstream file (<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>).
92-
{% if perms.extras.sync_configcontext %}
93-
<div class="float-end">
94-
<form action="{% url 'extras:configcontext_sync' pk=object.pk %}" method="post">
95-
{% csrf_token %}
96-
<button type="submit" class="btn btn-primary btn-sm">
97-
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync
98-
</button>
99-
</form>
100-
</div>
101-
{% endif %}
102-
</div>
103-
{% endif %}
104-
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
89+
{% include 'inc/sync_warning.html' %}
90+
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
10591
</div>
10692
</div>
10793
</div>

0 commit comments

Comments
 (0)