mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* Add sync_interval to DataSource * Enqueue a SyncDataSourceJob when needed after saving a DataSource * Fix logic for clearing pending jobs on interval change * Fix lingering background tasks after modifying DataSource
This commit is contained in:
parent
cf7e2c8dc9
commit
77b9820577
@ -44,6 +44,12 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza
|
|||||||
| `*.txt` | Ignore any files with a `.txt` extension |
|
| `*.txt` | Ignore any files with a `.txt` extension |
|
||||||
| `data???.json` | Ignore e.g. `data123.json` |
|
| `data???.json` | Ignore e.g. `data123.json` |
|
||||||
|
|
||||||
|
### Sync Interval
|
||||||
|
|
||||||
|
!!! info "This field was introduced in NetBox v4.3."
|
||||||
|
|
||||||
|
The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually.
|
||||||
|
|
||||||
### Last Synced
|
### Last Synced
|
||||||
|
|
||||||
The date and time at which the source was most recently synchronized successfully.
|
The date and time at which the source was most recently synchronized successfully.
|
||||||
|
@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||||||
model = DataSource
|
model = DataSource
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
|
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
|
||||||
'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced',
|
'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
'file_count',
|
'last_synced', 'file_count',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
@ -29,6 +29,10 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
|
|||||||
choices=DataSourceStatusChoices,
|
choices=DataSourceStatusChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
|
sync_interval = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=JobIntervalChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DataSource
|
model = DataSource
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.choices import JobIntervalChoices
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from netbox.utils import get_data_backend_choices
|
from netbox.utils import get_data_backend_choices
|
||||||
@ -29,6 +30,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
sync_interval = forms.ChoiceField(
|
||||||
|
choices=JobIntervalChoices,
|
||||||
|
required=False,
|
||||||
|
label=_('Sync interval')
|
||||||
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
parameters = forms.JSONField(
|
parameters = forms.JSONField(
|
||||||
label=_('Parameters'),
|
label=_('Parameters'),
|
||||||
@ -42,8 +48,8 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = DataSource
|
model = DataSource
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'),
|
FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
|
'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments',
|
||||||
)
|
)
|
||||||
|
@ -11,5 +11,6 @@ class DataSourceImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DataSource
|
model = DataSource
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules',
|
'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules',
|
||||||
|
'comments',
|
||||||
)
|
)
|
||||||
|
@ -27,7 +27,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
model = DataSource
|
model = DataSource
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('type', 'status', name=_('Data Source')),
|
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
@ -46,6 +46,11 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
sync_interval = forms.ChoiceField(
|
||||||
|
label=_('Sync interval'),
|
||||||
|
choices=JobIntervalChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataFileFilterForm(NetBoxModelFilterSetForm):
|
class DataFileFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
@ -36,7 +36,7 @@ class DataSourceForm(NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DataSource
|
model = DataSource
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
|
'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'ignore_rules': forms.Textarea(
|
'ignore_rules': forms.Textarea(
|
||||||
@ -51,7 +51,10 @@ class DataSourceForm(NetBoxModelForm):
|
|||||||
@property
|
@property
|
||||||
def fieldsets(self):
|
def fieldsets(self):
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')),
|
FieldSet(
|
||||||
|
'name', 'type', 'source_url', 'description', 'tags', 'ignore_rules', name=_('Source')
|
||||||
|
),
|
||||||
|
FieldSet('enabled', 'sync_interval', name=_('Sync')),
|
||||||
]
|
]
|
||||||
if self.backend_fields:
|
if self.backend_fields:
|
||||||
fieldsets.append(
|
fieldsets.append(
|
||||||
|
18
netbox/core/migrations/0013_datasource_sync_interval.py
Normal file
18
netbox/core/migrations/0013_datasource_sync_interval.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-02-26 19:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0012_job_object_type_optional'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='datasource',
|
||||||
|
name='sync_interval',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -59,6 +59,12 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
verbose_name=_('enabled'),
|
verbose_name=_('enabled'),
|
||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
|
sync_interval = models.PositiveSmallIntegerField(
|
||||||
|
verbose_name=_('sync interval'),
|
||||||
|
choices=JobIntervalChoices,
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
ignore_rules = models.TextField(
|
ignore_rules = models.TextField(
|
||||||
verbose_name=_('ignore rules'),
|
verbose_name=_('ignore rules'),
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -8,16 +8,15 @@ from django.dispatch import receiver, Signal
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||||
|
|
||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||||
from core.events import *
|
from core.events import *
|
||||||
from core.models import ObjectChange
|
|
||||||
from extras.events import enqueue_event
|
from extras.events import enqueue_event
|
||||||
from extras.utils import run_validators
|
from extras.utils import run_validators
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.context import current_request, events_queue
|
from netbox.context import current_request, events_queue
|
||||||
from netbox.models.features import ChangeLoggingMixin
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .models import ConfigRevision
|
from .models import ConfigRevision, DataSource, ObjectChange
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'clear_events',
|
'clear_events',
|
||||||
@ -182,6 +181,25 @@ def clear_events_queue(sender, **kwargs):
|
|||||||
# DataSource handlers
|
# DataSource handlers
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@receiver(post_save, sender=DataSource)
|
||||||
|
def enqueue_sync_job(instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate.
|
||||||
|
"""
|
||||||
|
from .jobs import SyncDataSourceJob
|
||||||
|
|
||||||
|
if instance.enabled and instance.sync_interval:
|
||||||
|
SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval)
|
||||||
|
elif not created:
|
||||||
|
# Delete any previously scheduled recurring jobs for this DataSource
|
||||||
|
for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter(
|
||||||
|
interval__isnull=False,
|
||||||
|
status=JobStatusChoices.STATUS_SCHEDULED
|
||||||
|
):
|
||||||
|
# Call delete() per instance to ensure the associated background task is deleted as well
|
||||||
|
job.delete()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_sync)
|
@receiver(post_sync)
|
||||||
def auto_sync(instance, **kwargs):
|
def auto_sync(instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -25,6 +25,9 @@ class DataSourceTable(NetBoxTable):
|
|||||||
enabled = columns.BooleanColumn(
|
enabled = columns.BooleanColumn(
|
||||||
verbose_name=_('Enabled'),
|
verbose_name=_('Enabled'),
|
||||||
)
|
)
|
||||||
|
sync_interval = columns.ChoiceFieldColumn(
|
||||||
|
verbose_name=_('Sync interval'),
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='core:datasource_list'
|
url_name='core:datasource_list'
|
||||||
)
|
)
|
||||||
@ -35,10 +38,10 @@ class DataSourceTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = DataSource
|
model = DataSource
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
|
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments',
|
||||||
'created', 'last_updated', 'file_count',
|
'parameters', 'created', 'last_updated', 'file_count',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
|
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'sync_interval', 'file_count')
|
||||||
|
|
||||||
|
|
||||||
class DataFileTable(NetBoxTable):
|
class DataFileTable(NetBoxTable):
|
||||||
|
@ -27,7 +27,8 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
source_url='file:///var/tmp/source1/',
|
source_url='file:///var/tmp/source1/',
|
||||||
status=DataSourceStatusChoices.NEW,
|
status=DataSourceStatusChoices.NEW,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
description='foobar1'
|
description='foobar1',
|
||||||
|
sync_interval=JobIntervalChoices.INTERVAL_HOURLY
|
||||||
),
|
),
|
||||||
DataSource(
|
DataSource(
|
||||||
name='Data Source 2',
|
name='Data Source 2',
|
||||||
@ -35,14 +36,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
source_url='file:///var/tmp/source2/',
|
source_url='file:///var/tmp/source2/',
|
||||||
status=DataSourceStatusChoices.SYNCING,
|
status=DataSourceStatusChoices.SYNCING,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
description='foobar2'
|
description='foobar2',
|
||||||
|
sync_interval=JobIntervalChoices.INTERVAL_DAILY
|
||||||
),
|
),
|
||||||
DataSource(
|
DataSource(
|
||||||
name='Data Source 3',
|
name='Data Source 3',
|
||||||
type='git',
|
type='git',
|
||||||
source_url='https://example.com/git/source3',
|
source_url='https://example.com/git/source3',
|
||||||
status=DataSourceStatusChoices.COMPLETED,
|
status=DataSourceStatusChoices.COMPLETED,
|
||||||
enabled=False
|
enabled=False,
|
||||||
|
sync_interval=JobIntervalChoices.INTERVAL_WEEKLY
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
DataSource.objects.bulk_create(data_sources)
|
DataSource.objects.bulk_create(data_sources)
|
||||||
@ -73,6 +76,10 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
|
params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_sync_interval(self):
|
||||||
|
params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = DataFile.objects.all()
|
queryset = DataFile.objects.all()
|
||||||
|
@ -46,6 +46,10 @@
|
|||||||
<th scope="row">{% trans "Status" %}</th>
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Sync interval" %}</th>
|
||||||
|
<td>{{ object.get_sync_interval_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Last synced" %}</th>
|
<th scope="row">{% trans "Last synced" %}</th>
|
||||||
<td>{{ object.last_synced|placeholder }}</td>
|
<td>{{ object.last_synced|placeholder }}</td>
|
||||||
|
Loading…
Reference in New Issue
Block a user