1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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
This commit is contained in:
Jeremy Stretch
2023-02-07 16:44:05 -05:00
committed by jeremystretch
parent 664132281e
commit 678a7d17df
20 changed files with 426 additions and 94 deletions

View File

@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont
The context data expressed in JSON format.
### Data File
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.
### Is Active
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.

View File

@ -4,6 +4,7 @@
### Enhancements
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
* [#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
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI

View File

@ -1,5 +1,6 @@
import logging
import os
import yaml
from fnmatch import fnmatchcase
from urllib.parse import urlparse
@ -283,6 +284,13 @@ class DataFile(ChangeLoggingMixin, models.Model):
except UnicodeDecodeError:
return None
def get_data(self):
"""
Attempt to read the file data as JSON/YAML and return a native Python object.
"""
# TODO: Something more robust
return yaml.safe_load(self.data_as_string)
def refresh_from_disk(self, source_root):
"""
Update instance attributes from the file on disk. Returns True if any attribute

View File

@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
from dcim.api.nested_serializers import (
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@ -358,13 +359,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
data_source = NestedDataSourceSerializer(
required=False
)
data_file = NestedDataFileSerializer(
read_only=True
)
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated',
]

View File

@ -17,6 +17,7 @@ from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
# Config contexts
#
class ConfigContextViewSet(NetBoxModelViewSet):
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContext.objects.prefetch_related(
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
'data_file',
)
serializer_class = serializers.ConfigContextSerializer
filterset_class = filtersets.ConfigContextFilterSet

View File

@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
'export_templates',
'job_results',
'journaling',
'synced_data',
'tags',
'webhooks'
]

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
@ -422,10 +423,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
to_field_name='slug',
label=_('Tag (slug)'),
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data file (ID)'),
)
class Meta:
model = ConfigContext
fields = ['id', 'name', 'is_active']
fields = ['id', 'name', 'is_active', 'data_synced']
def search(self, queryset, name, value):
if not value.strip():

View File

@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
@ -257,11 +258,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag_id')),
('Data', ('data_source_id', 'data_file_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Device', ('device_type_id', 'platform_id', 'role_id')),
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
('Tenant', ('tenant_group_id', 'tenant_id'))
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file_id = DynamicModelMultipleChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('Data file'),
query_params={
'source_id': '$data_source_id'
}
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType
from django import forms
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from extras.models import *
from extras.choices import CustomFieldVisibilityChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
'SyncedDataMixin',
)
@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
'usable': True,
}
)
class SyncedDataMixin(forms.Form):
data_source = DynamicModelChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file = DynamicModelChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('File'),
query_params={
'source_id': '$data_source',
}
)

View File

@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.forms.mixins import SyncedDataMixin
from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelForm
@ -183,7 +184,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
]
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
regions = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False
@ -236,10 +237,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
queryset=Tag.objects.all(),
required=False
)
data = JSONField()
data = JSONField(
required=False
)
fieldsets = (
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
('Data Source', ('data_source', 'data_file')),
('Assignment', (
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@ -251,9 +255,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags',
'tenants', 'tags', 'data_source', 'data_file',
)
def clean(self):
super().clean()
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_source'):
raise forms.ValidationError("Must specify either local data or a data source")
return self.cleaned_data
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):

View File

@ -0,0 +1,35 @@
# Generated by Django 4.1.6 on 2023-02-06 15:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('extras', '0084_staging'),
]
operations = [
migrations.AddField(
model_name='configcontext',
name='data_file',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
),
migrations.AddField(
model_name='configcontext',
name='data_path',
field=models.CharField(blank=True, editable=False, max_length=1000),
),
migrations.AddField(
model_name='configcontext',
name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
),
migrations.AddField(
model_name='configcontext',
name='data_synced',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@ -2,10 +2,11 @@ from django.conf import settings
from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from netbox.models.features import SyncedDataMixin, WebhooksMixin
from utilities.utils import deepmerge
@ -19,7 +20,7 @@ __all__ = (
# Config contexts
#
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
class ConfigContext(SyncedDataMixin, WebhooksMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@ -130,6 +131,13 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
def sync_data(self):
"""
Synchronize context data from the designated DataFile (if any).
"""
self.data = self.data_file.get_data()
self.data_synced = timezone.now()
class ConfigContextModel(models.Model):
"""

View File

@ -188,21 +188,30 @@ class TaggedItemTable(NetBoxTable):
class ConfigContextTable(NetBoxTable):
data_source = tables.Column(
linkify=True
)
data_file = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True
)
is_active = columns.BooleanColumn(
verbose_name='Active'
)
is_synced = columns.BooleanColumn(
verbose_name='Synced'
)
class Meta(NetBoxTable.Meta):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
'last_updated',
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
class ObjectChangeTable(NetBoxTable):

View File

@ -60,6 +60,7 @@ urlpatterns = [
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
# Image attachments

View File

@ -352,7 +352,8 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
actions = ('add', 'bulk_edit', 'bulk_delete')
template_name = 'extras/configcontext_list.html'
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
@register_model_view(ConfigContext)
@ -416,6 +417,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
table = tables.ConfigContextTable
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
queryset = ConfigContext.objects.all()
class ObjectConfigContextView(generic.ObjectView):
base_template = None
template_name = 'extras/object_configcontext.html'

View File

@ -0,0 +1,30 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from utilities.permissions import get_permission_for_model
__all__ = (
'SyncedDataMixin',
)
class SyncedDataMixin:
@action(detail=True, methods=['post'])
def sync(self, request, pk):
"""
Provide a /sync API endpoint to synchronize an object's data from its associated DataFile (if any).
"""
permission = get_permission_for_model(self.queryset.model, 'sync')
if not request.user.has_perm(permission):
raise PermissionDenied(f"Missing permission: {permission}")
obj = get_object_or_404(self.queryset, pk=pk)
if obj.data_file:
obj.sync_data()
obj.save()
serializer = self.serializer_class(obj, context={'request': request})
return Response(serializer.data)

View File

@ -2,11 +2,12 @@ from collections import defaultdict
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db.models.signals import class_prepared
from django.dispatch import receiver
from django.core.validators import ValidationError
from django.db import models
from django.db.models.signals import class_prepared
from django.dispatch import receiver
from django.utils.translation import gettext as _
from taggit.managers import TaggableManager
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
@ -25,6 +26,7 @@ __all__ = (
'ExportTemplatesMixin',
'JobResultsMixin',
'JournalingMixin',
'SyncedDataMixin',
'TagsMixin',
'WebhooksMixin',
)
@ -317,12 +319,82 @@ class WebhooksMixin(models.Model):
abstract = True
class SyncedDataMixin(models.Model):
"""
Enables population of local data from a DataFile object, synchronized from a remote DatSource.
"""
data_source = models.ForeignKey(
to='core.DataSource',
on_delete=models.PROTECT,
blank=True,
null=True,
related_name='+',
help_text=_("Remote data source")
)
data_file = models.ForeignKey(
to='core.DataFile',
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name='+'
)
data_path = models.CharField(
max_length=1000,
blank=True,
editable=False,
help_text=_("Path to remote file (relative to data source root)")
)
data_synced = models.DateTimeField(
blank=True,
null=True,
editable=False
)
class Meta:
abstract = True
@property
def is_synced(self):
return self.data_file and self.data_synced >= self.data_file.last_updated
def clean(self):
if self.data_file:
self.sync_data()
self.data_path = self.data_file.path
if self.data_source and not self.data_file:
raise ValidationError({
'data_file': _(f"Must specify a data file when designating a data source.")
})
if self.data_file and not self.data_source:
self.data_source = self.data_file.source
super().clean()
def resolve_data_file(self):
"""
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
either attribute is unset, or if no matching DataFile is found.
"""
from core.models import DataFile
if self.data_source and self.data_path:
try:
return DataFile.objects.get(source=self.data_source, path=self.data_path)
except DataFile.DoesNotExist:
pass
def sync_data(self):
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
FEATURES_MAP = (
('custom_fields', CustomFieldsMixin),
('custom_links', CustomLinksMixin),
('export_templates', ExportTemplatesMixin),
('job_results', JobResultsMixin),
('journaling', JournalingMixin),
('synced_data', SyncedDataMixin),
('tags', TagsMixin),
('webhooks', WebhooksMixin),
)
@ -348,3 +420,9 @@ def _register_features(sender, **kwargs):
'changelog',
kwargs={'model': sender}
)('netbox.views.generic.ObjectChangeLogView')
if issubclass(sender, SyncedDataMixin):
register_model_view(
sender,
'sync',
kwargs={'model': sender}
)('netbox.views.generic.ObjectSyncDataView')

View File

@ -1,16 +1,22 @@
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext as _
from django.views.generic import View
from extras import forms, tables
from extras.models import *
from utilities.views import ViewTab
from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin, ViewTab
from .base import BaseMultiObjectView
__all__ = (
'BulkSyncDataView',
'ObjectChangeLogView',
'ObjectJournalView',
'ObjectSyncDataView',
)
@ -126,3 +132,49 @@ class ObjectJournalView(View):
'base_template': self.base_template,
'tab': self.tab,
})
class ObjectSyncDataView(View):
def post(self, request, model, **kwargs):
"""
Synchronize data from the DataFile associated with this object.
"""
qs = model.objects.all()
if hasattr(model.objects, 'restrict'):
qs = qs.restrict(request.user, 'sync')
obj = get_object_or_404(qs, **kwargs)
if not obj.data_file:
messages.error(request, f"Unable to synchronize data: No data file set.")
return redirect(obj.get_absolute_url())
obj.sync_data()
obj.save()
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
return redirect(obj.get_absolute_url())
class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
"""
Synchronize multiple instances of a model inheriting from SyncedDataMixin.
"""
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'sync')
def post(self, request):
selected_objects = self.queryset.filter(
pk__in=request.POST.getlist('pk'),
data_file__isnull=False
)
with transaction.atomic():
for obj in selected_objects:
obj.sync_data()
obj.save()
model_name = self.queryset.model._meta.verbose_name_plural
messages.success(request, f"Synced {len(selected_objects)} {model_name}")
return redirect(self.get_return_url(request))

View File

@ -3,81 +3,107 @@
{% load static %}
{% block content %}
<div class="row">
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">
Config Context
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>
{{ object.weight }}
</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Active</th>
<td>
{% if object.is_active %}
<span class="text-success">
<i class="mdi mdi-check-bold"></i>
</span>
{% else %}
<span class="text-danger">
<i class="mdi mdi-close"></i>
</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Assignment
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for title, objects in assigned_objects %}
<tr>
<th scope="row">{{ title }}</th>
<td>
<ul class="list-unstyled mb-0">
{% for object in objects %}
<li>{{ object|linkify }}</li>
{% empty %}
<li class="text-muted">None</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">Config Context</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Active</th>
<td>{% checkmark object.is_active %}</td>
</tr>
<tr>
<th scope="row">Data Source</th>
<td>
{% if object.data_source %}
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Data File</th>
<td>
{% if object.data_file %}
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
{% elif object.data_path %}
<div class="float-end text-warning">
<i class="mdi mdi-alert" title="The data file associated with this object has been deleted."></i>
</div>
{{ object.data_path }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Data Synced</th>
<td>{{ object.data_synced|placeholder }}</td>
</tr>
</table>
</div>
<div class="col col-md-7">
<div class="card">
<div class="card-header">
<h5>Data</h5>
{% include 'extras/inc/configcontext_format.html' %}
</div>
<div class="card-body">
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
</div>
</div>
</div>
<div class="card">
<h5 class="card-header">Assignment</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for title, objects in assigned_objects %}
<tr>
<th scope="row">{{ title }}</th>
<td>
<ul class="list-unstyled mb-0">
{% for object in objects %}
<li>{{ object|linkify }}</li>
{% empty %}
<li class="text-muted">None</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
<div class="col col-md-7">
<div class="card">
<div class="card-header">
<h5>Data</h5>
{% include 'extras/inc/configcontext_format.html' %}
</div>
<div class="card-body">
{% if object.data_file and object.data_file.last_updated > object.data_synced %}
<div class="alert alert-warning" role="alert">
<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>).
{% if perms.extras.sync_configcontext %}
<div class="float-end">
<form action="{% url 'extras:configcontext_sync' pk=object.pk %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary btn-sm">
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync
</button>
</form>
</div>
{% endif %}
</div>
{% endif %}
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends 'generic/object_list.html' %}
{% block bulk_buttons %}
{% if perms.extras.sync_configcontext %}
<button type="submit" name="_sync" formaction="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync Data
</button>
{% endif %}
{{ block.super }}
{% endblock %}