Просмотр исходного кода

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

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

* Cleanup
Jeremy Stretch 2 лет назад
Родитель
Сommit
8b040ff930

+ 2 - 2
netbox/core/forms/model_forms.py

@@ -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()

+ 17 - 0
netbox/core/migrations/0001_initial.py

@@ -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'),
+        ),
     ]
     ]

+ 1 - 0
netbox/core/migrations/0002_managedfile.py

@@ -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'),

+ 35 - 1
netbox/core/models/data.py

@@ -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')),
+        )

+ 14 - 3
netbox/core/signals.py

@@ -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()
-post_sync = django.dispatch.Signal()
+pre_sync = 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)

+ 4 - 4
netbox/extras/forms/model_forms.py

@@ -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:

+ 10 - 0
netbox/extras/migrations/0085_synced_data.py

@@ -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',

+ 1 - 0
netbox/extras/migrations/0086_configtemplate.py

@@ -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={

+ 1 - 2
netbox/netbox/api/features.py

@@ -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.save()
+            obj.sync(save=True)
         serializer = self.serializer_class(obj, context={'request': request})
         serializer = self.serializer_class(obj, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)

+ 33 - 1
netbox/netbox/models/features.py

@@ -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):
         """
         """

+ 2 - 4
netbox/netbox/views/generic/feature_views.py

@@ -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.save()
+        obj.sync(save=True)
         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.save()
+                obj.sync(save=True)
 
 
             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}")