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

Closes #12129: Enable automatic synchronization of objects when DataFiles are updated (#12262)

* Closes #12129: Enable automatic synchronization of objects when DataFiles are updated

* Cleanup
This commit is contained in:
Jeremy Stretch
2023-04-17 10:35:17 -04:00
committed by GitHub
parent d470848b29
commit 8b040ff930
11 changed files with 120 additions and 17 deletions

View File

@ -80,12 +80,12 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
fieldsets = ( fieldsets = (
('File Upload', ('upload_file',)), ('File Upload', ('upload_file',)),
('Data Source', ('data_source', 'data_file')), ('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
) )
class Meta: class Meta:
model = ManagedFile model = ManagedFile
fields = ('data_source', 'data_file') fields = ('data_source', 'data_file', 'auto_sync_enabled')
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -63,4 +63,21 @@ class Migration(migrations.Migration):
model_name='datafile', model_name='datafile',
index=models.Index(fields=['source', 'path'], name='core_datafile_source_path'), index=models.Index(fields=['source', 'path'], name='core_datafile_source_path'),
), ),
migrations.CreateModel(
name='AutoSyncRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.PositiveBigIntegerField()),
('datafile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile')),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
],
),
migrations.AddIndex(
model_name='autosyncrecord',
index=models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx'),
),
migrations.AddConstraint(
model_name='autosyncrecord',
constraint=models.UniqueConstraint(fields=('object_type', 'object_id'), name='core_autosyncrecord_object'),
),
] ]

View File

@ -23,6 +23,7 @@ class Migration(migrations.Migration):
('file_path', models.FilePathField(editable=False)), ('file_path', models.FilePathField(editable=False)),
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')), ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')), ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
('auto_sync_enabled', models.BooleanField(default=False)),
], ],
options={ options={
'ordering': ('file_root', 'file_path'), 'ordering': ('file_root', 'file_path'),

View File

@ -5,7 +5,8 @@ from fnmatch import fnmatchcase
from urllib.parse import urlparse from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
@ -25,6 +26,7 @@ from ..signals import post_sync, pre_sync
from .jobs import Job from .jobs import Job
__all__ = ( __all__ = (
'AutoSyncRecord',
'DataFile', 'DataFile',
'DataSource', 'DataSource',
) )
@ -327,3 +329,35 @@ class DataFile(models.Model):
with open(path, 'wb+') as new_file: with open(path, 'wb+') as new_file:
new_file.write(self.data) new_file.write(self.data)
class AutoSyncRecord(models.Model):
"""
Maps a DataFile to a synced object for efficient automatic updating.
"""
datafile = models.ForeignKey(
to=DataFile,
on_delete=models.CASCADE,
related_name='+'
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
class Meta:
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id'),
name='%(app_label)s_%(class)s_object'
),
)
indexes = (
models.Index(fields=('object_type', 'object_id')),
)

View File

@ -1,4 +1,4 @@
import django.dispatch from django.dispatch import Signal, receiver
__all__ = ( __all__ = (
'post_sync', 'post_sync',
@ -6,5 +6,16 @@ __all__ = (
) )
# DataSource signals # DataSource signals
pre_sync = django.dispatch.Signal() pre_sync = Signal()
post_sync = django.dispatch.Signal() post_sync = Signal()
@receiver(post_sync)
def auto_sync(instance, **kwargs):
"""
Automatically synchronize any DataFiles with AutoSyncRecords after synchronizing a DataSource.
"""
from .models import AutoSyncRecord
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
autosync.object.sync(save=True)

View File

@ -112,7 +112,7 @@ class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
('Export Template', ('name', 'content_types', 'description', 'template_code')), ('Export Template', ('name', 'content_types', 'description', 'template_code')),
('Data Source', ('data_source', 'data_file')), ('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
) )
@ -271,7 +271,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
('Data Source', ('data_source', 'data_file')), ('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
('Assignment', ( ('Assignment', (
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@ -283,7 +283,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fields = ( fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data_source', 'data_file', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
) )
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
@ -322,7 +322,7 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
('Config Template', ('name', 'description', 'environment_params', 'tags')), ('Config Template', ('name', 'description', 'environment_params', 'tags')),
('Content', ('template_code',)), ('Content', ('template_code',)),
('Data Source', ('data_source', 'data_file')), ('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
) )
class Meta: class Meta:

View File

@ -26,6 +26,11 @@ class Migration(migrations.Migration):
name='data_source', name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), 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='auto_sync_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField( migrations.AddField(
model_name='configcontext', model_name='configcontext',
name='data_synced', name='data_synced',
@ -47,6 +52,11 @@ class Migration(migrations.Migration):
name='data_source', name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
), ),
migrations.AddField(
model_name='exporttemplate',
name='auto_sync_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField( migrations.AddField(
model_name='exporttemplate', model_name='exporttemplate',
name='data_synced', name='data_synced',

View File

@ -25,6 +25,7 @@ class Migration(migrations.Migration):
('environment_params', models.JSONField(blank=True, default=dict, null=True)), ('environment_params', models.JSONField(blank=True, default=dict, null=True)),
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')), ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')), ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
('auto_sync_enabled', models.BooleanField(default=False)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
], ],
options={ options={

View File

@ -23,8 +23,7 @@ class SyncedDataMixin:
obj = get_object_or_404(self.queryset, pk=pk) obj = get_object_or_404(self.queryset, pk=pk)
if obj.data_file: if obj.data_file:
obj.sync() obj.sync(save=True)
obj.save()
serializer = self.serializer_class(obj, context={'request': request}) serializer = self.serializer_class(obj, context={'request': request})
return Response(serializer.data) return Response(serializer.data)

View File

@ -3,6 +3,7 @@ from collections import defaultdict
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models.signals import class_prepared from django.db.models.signals import class_prepared
@ -382,6 +383,10 @@ class SyncedDataMixin(models.Model):
editable=False, editable=False,
help_text=_("Path to remote file (relative to data source root)") help_text=_("Path to remote file (relative to data source root)")
) )
auto_sync_enabled = models.BooleanField(
default=False,
help_text=_("Enable automatic synchronization of data when the data file is updated")
)
data_synced = models.DateTimeField( data_synced = models.DateTimeField(
blank=True, blank=True,
null=True, null=True,
@ -404,10 +409,33 @@ class SyncedDataMixin(models.Model):
else: else:
self.data_source = None self.data_source = None
self.data_path = '' self.data_path = ''
self.auto_sync_enabled = False
self.data_synced = None self.data_synced = None
super().clean() super().clean()
def save(self, *args, **kwargs):
from core.models import AutoSyncRecord
ret = super().save(*args, **kwargs)
# Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled:
AutoSyncRecord.objects.get_or_create(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
)
else:
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
).delete()
return ret
def resolve_data_file(self): def resolve_data_file(self):
""" """
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
@ -421,13 +449,17 @@ class SyncedDataMixin(models.Model):
except DataFile.DoesNotExist: except DataFile.DoesNotExist:
pass pass
def sync(self): def sync(self, save=False):
""" """
Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates
the synced_data timestamp. the synced_data timestamp.
:param save: If true, save() will be called after data has been synchronized
""" """
self.sync_data() self.sync_data()
self.data_synced = timezone.now() self.data_synced = timezone.now()
if save:
self.save()
def sync_data(self): def sync_data(self):
""" """

View File

@ -205,8 +205,7 @@ class ObjectSyncDataView(View):
messages.error(request, f"Unable to synchronize data: No data file set.") messages.error(request, f"Unable to synchronize data: No data file set.")
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
obj.sync() obj.sync(save=True)
obj.save()
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.") messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
@ -227,8 +226,7 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
with transaction.atomic(): with transaction.atomic():
for obj in selected_objects: for obj in selected_objects:
obj.sync() obj.sync(save=True)
obj.save()
model_name = self.queryset.model._meta.verbose_name_plural model_name = self.queryset.model._meta.verbose_name_plural
messages.success(request, f"Synced {len(selected_objects)} {model_name}") messages.success(request, f"Synced {len(selected_objects)} {model_name}")