Skip to content

Commit 8090497

Browse files
committed
Closes #12129: Enable automatic synchronization of objects when DataFiles are updated
1 parent d470848 commit 8090497

File tree

11 files changed

+118
-17
lines changed

11 files changed

+118
-17
lines changed

netbox/core/forms/model_forms.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
8080

8181
fieldsets = (
8282
('File Upload', ('upload_file',)),
83-
('Data Source', ('data_source', 'data_file')),
83+
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
8484
)
8585

8686
class Meta:
8787
model = ManagedFile
88-
fields = ('data_source', 'data_file')
88+
fields = ('data_source', 'data_file', 'auto_sync_enabled')
8989

9090
def clean(self):
9191
super().clean()

netbox/core/migrations/0001_initial.py

+17
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,21 @@ class Migration(migrations.Migration):
6363
model_name='datafile',
6464
index=models.Index(fields=['source', 'path'], name='core_datafile_source_path'),
6565
),
66+
migrations.CreateModel(
67+
name='AutoSyncRecord',
68+
fields=[
69+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
70+
('object_id', models.PositiveBigIntegerField()),
71+
('datafile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile')),
72+
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
73+
],
74+
),
75+
migrations.AddIndex(
76+
model_name='autosyncrecord',
77+
index=models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx'),
78+
),
79+
migrations.AddConstraint(
80+
model_name='autosyncrecord',
81+
constraint=models.UniqueConstraint(fields=('object_type', 'object_id'), name='core_autosyncrecord_object'),
82+
),
6683
]

netbox/core/migrations/0002_managedfile.py

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Migration(migrations.Migration):
2323
('file_path', models.FilePathField(editable=False)),
2424
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
2525
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
26+
('auto_sync_enabled', models.BooleanField(default=False)),
2627
],
2728
options={
2829
'ordering': ('file_root', 'file_path'),

netbox/core/models/data.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from urllib.parse import urlparse
66

77
from django.conf import settings
8-
from django.contrib.contenttypes.fields import GenericRelation
8+
from django.contrib.contenttypes.fields import GenericForeignKey
9+
from django.contrib.contenttypes.models import ContentType
910
from django.core.exceptions import ValidationError
1011
from django.core.validators import RegexValidator
1112
from django.db import models
@@ -25,6 +26,7 @@
2526
from .jobs import Job
2627

2728
__all__ = (
29+
'AutoSyncRecord',
2830
'DataFile',
2931
'DataSource',
3032
)
@@ -327,3 +329,35 @@ def write_to_disk(self, path, overwrite=False):
327329

328330
with open(path, 'wb+') as new_file:
329331
new_file.write(self.data)
332+
333+
334+
class AutoSyncRecord(models.Model):
335+
"""
336+
Maps a DataFile to a synced object for efficient automatic updating.
337+
"""
338+
datafile = models.ForeignKey(
339+
to=DataFile,
340+
on_delete=models.CASCADE,
341+
related_name='+'
342+
)
343+
object_type = models.ForeignKey(
344+
to=ContentType,
345+
on_delete=models.CASCADE,
346+
related_name='+'
347+
)
348+
object_id = models.PositiveBigIntegerField()
349+
object = GenericForeignKey(
350+
ct_field='object_type',
351+
fk_field='object_id'
352+
)
353+
354+
class Meta:
355+
constraints = (
356+
models.UniqueConstraint(
357+
fields=('object_type', 'object_id'),
358+
name='%(app_label)s_%(class)s_object'
359+
),
360+
)
361+
indexes = (
362+
models.Index(fields=('object_type', 'object_id')),
363+
)

netbox/core/signals.py

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1-
import django.dispatch
1+
from django.dispatch import Signal, receiver
22

33
__all__ = (
44
'post_sync',
55
'pre_sync',
66
)
77

88
# DataSource signals
9-
pre_sync = django.dispatch.Signal()
10-
post_sync = django.dispatch.Signal()
9+
pre_sync = Signal()
10+
post_sync = Signal()
11+
12+
13+
@receiver(post_sync)
14+
def auto_sync(instance, **kwargs):
15+
"""
16+
Automatically synchronize any DataFiles with AutoSyncRecords after synchronizing a DataSource.
17+
"""
18+
from .models import AutoSyncRecord
19+
20+
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance):
21+
autosync.object.sync(save=True)

netbox/extras/forms/model_forms.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
112112

113113
fieldsets = (
114114
('Export Template', ('name', 'content_types', 'description', 'template_code')),
115-
('Data Source', ('data_source', 'data_file')),
115+
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
116116
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
117117
)
118118

@@ -271,7 +271,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
271271

272272
fieldsets = (
273273
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
274-
('Data Source', ('data_source', 'data_file')),
274+
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
275275
('Assignment', (
276276
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
277277
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@@ -283,7 +283,7 @@ class Meta:
283283
fields = (
284284
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
285285
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
286-
'tenants', 'tags', 'data_source', 'data_file',
286+
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
287287
)
288288

289289
def __init__(self, *args, initial=None, **kwargs):
@@ -322,7 +322,7 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
322322
fieldsets = (
323323
('Config Template', ('name', 'description', 'environment_params', 'tags')),
324324
('Content', ('template_code',)),
325-
('Data Source', ('data_source', 'data_file')),
325+
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
326326
)
327327

328328
class Meta:

netbox/extras/migrations/0085_synced_data.py

+10
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ class Migration(migrations.Migration):
2626
name='data_source',
2727
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
2828
),
29+
migrations.AddField(
30+
model_name='configcontext',
31+
name='auto_sync_enabled',
32+
field=models.BooleanField(default=False),
33+
),
2934
migrations.AddField(
3035
model_name='configcontext',
3136
name='data_synced',
@@ -47,6 +52,11 @@ class Migration(migrations.Migration):
4752
name='data_source',
4853
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
4954
),
55+
migrations.AddField(
56+
model_name='exporttemplate',
57+
name='auto_sync_enabled',
58+
field=models.BooleanField(default=False),
59+
),
5060
migrations.AddField(
5161
model_name='exporttemplate',
5262
name='data_synced',

netbox/extras/migrations/0086_configtemplate.py

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

netbox/netbox/api/features.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ def sync(self, request, pk):
2323

2424
obj = get_object_or_404(self.queryset, pk=pk)
2525
if obj.data_file:
26-
obj.sync()
27-
obj.save()
26+
obj.sync(save=True)
2827
serializer = self.serializer_class(obj, context={'request': request})
2928

3029
return Response(serializer.data)

netbox/netbox/models/features.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from functools import cached_property
44

55
from django.contrib.contenttypes.fields import GenericRelation
6+
from django.contrib.contenttypes.models import ContentType
67
from django.core.validators import ValidationError
78
from django.db import models
89
from django.db.models.signals import class_prepared
@@ -382,6 +383,10 @@ class SyncedDataMixin(models.Model):
382383
editable=False,
383384
help_text=_("Path to remote file (relative to data source root)")
384385
)
386+
auto_sync_enabled = models.BooleanField(
387+
default=False,
388+
help_text=_("Enable automatic synchronization of data when the data file is updated")
389+
)
385390
data_synced = models.DateTimeField(
386391
blank=True,
387392
null=True,
@@ -404,10 +409,33 @@ def clean(self):
404409
else:
405410
self.data_source = None
406411
self.data_path = ''
412+
self.auto_sync_enabled = False
407413
self.data_synced = None
408414

409415
super().clean()
410416

417+
def save(self, *args, **kwargs):
418+
from core.models import AutoSyncRecord
419+
420+
ret = super().save(*args, **kwargs)
421+
422+
# Create/delete AutoSyncRecord as needed
423+
content_type = ContentType.objects.get_for_model(self)
424+
if self.auto_sync_enabled:
425+
AutoSyncRecord.objects.get_or_create(
426+
datafile=self.data_file,
427+
object_type=content_type,
428+
object_id=self.pk
429+
)
430+
else:
431+
AutoSyncRecord.objects.filter(
432+
datafile=self.data_file,
433+
object_type=content_type,
434+
object_id=self.pk
435+
).delete()
436+
437+
return ret
438+
411439
def resolve_data_file(self):
412440
"""
413441
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
@@ -421,13 +449,15 @@ def resolve_data_file(self):
421449
except DataFile.DoesNotExist:
422450
pass
423451

424-
def sync(self):
452+
def sync(self, save=False):
425453
"""
426454
Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates
427455
the synced_data timestamp.
428456
"""
429457
self.sync_data()
430458
self.data_synced = timezone.now()
459+
if save:
460+
self.save()
431461

432462
def sync_data(self):
433463
"""

netbox/netbox/views/generic/feature_views.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,7 @@ def post(self, request, model, **kwargs):
205205
messages.error(request, f"Unable to synchronize data: No data file set.")
206206
return redirect(obj.get_absolute_url())
207207

208-
obj.sync()
209-
obj.save()
208+
obj.sync(save=True)
210209
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
211210

212211
return redirect(obj.get_absolute_url())
@@ -227,8 +226,7 @@ def post(self, request):
227226

228227
with transaction.atomic():
229228
for obj in selected_objects:
230-
obj.sync()
231-
obj.save()
229+
obj.sync(save=True)
232230

233231
model_name = self.queryset.model._meta.verbose_name_plural
234232
messages.success(request, f"Synced {len(selected_objects)} {model_name}")

0 commit comments

Comments
 (0)