ipfabric_netbox 4.3.0b6__py3-none-any.whl → 4.3.0b8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ipfabric_netbox might be problematic. Click here for more details.

@@ -6,7 +6,7 @@ class NetboxIPFabricConfig(PluginConfig):
6
6
  name = "ipfabric_netbox"
7
7
  verbose_name = "NetBox IP Fabric SoT Plugin"
8
8
  description = "Sync IP Fabric into NetBox"
9
- version = "4.3.0b6"
9
+ version = "4.3.0b8"
10
10
  base_url = "ipfabric"
11
11
  min_version = "4.4.0"
12
12
 
@@ -176,6 +176,7 @@ class IPFabricSnapshotSerializer(NestedGroupModelSerializer):
176
176
 
177
177
  class IPFabricSyncSerializer(NestedGroupModelSerializer):
178
178
  snapshot_data = IPFabricSnapshotSerializer(nested=True)
179
+ parameters = serializers.JSONField()
179
180
 
180
181
  class Meta:
181
182
  model = IPFabricSync
@@ -244,3 +245,7 @@ class IPFabricIngestionIssueSerializer(NestedGroupModelSerializer):
244
245
  "message",
245
246
  "model",
246
247
  )
248
+
249
+
250
+ class EmptySerializer(serializers.Serializer):
251
+ pass
@@ -1,10 +1,15 @@
1
+ from core.api.serializers_.jobs import JobSerializer
2
+ from django.core.exceptions import PermissionDenied
1
3
  from django.db import transaction
4
+ from django.http import HttpResponseBadRequest
5
+ from drf_spectacular.utils import extend_schema
2
6
  from netbox.api.viewsets import NetBoxModelViewSet
3
7
  from netbox.api.viewsets import NetBoxReadOnlyModelViewSet
4
8
  from rest_framework.decorators import action
5
9
  from rest_framework.response import Response
6
10
  from utilities.query import count_related
7
11
 
12
+ from .serializers import EmptySerializer
8
13
  from .serializers import IPFabricIngestionIssueSerializer
9
14
  from .serializers import IPFabricIngestionSerializer
10
15
  from .serializers import IPFabricRelationshipFieldSerializer
@@ -56,6 +61,27 @@ class IPFabricSyncViewSet(NetBoxModelViewSet):
56
61
  queryset = IPFabricSync.objects.all()
57
62
  serializer_class = IPFabricSyncSerializer
58
63
 
64
+ @extend_schema(
65
+ methods=["post"],
66
+ request=EmptySerializer(),
67
+ responses={201: JobSerializer()},
68
+ )
69
+ @action(detail=True, methods=["post"])
70
+ def sync(self, request, pk):
71
+ if not request.user.has_perm("ipfabric_netbox.sync_ipfabricsync"):
72
+ raise PermissionDenied(
73
+ "This user does not have permission to sync IPFabricSync."
74
+ )
75
+ sync = self.get_object()
76
+ if not sync.ready_for_sync:
77
+ return HttpResponseBadRequest(
78
+ f"Sync '{sync.name}' is not ready to be synced."
79
+ )
80
+ job = sync.enqueue_sync_job(user=request.user, adhoc=True)
81
+ return Response(
82
+ JobSerializer(job, context={"request": request}).data, status=201
83
+ )
84
+
59
85
 
60
86
  class IPFabricIngestionViewSet(NetBoxReadOnlyModelViewSet):
61
87
  queryset = IPFabricIngestion.objects.all()
@@ -122,3 +148,24 @@ class IPFabricSourceViewSet(NetBoxModelViewSet):
122
148
  )
123
149
  serializer_class = IPFabricSourceSerializer
124
150
  filterset_class = IPFabricSourceFilterSet
151
+
152
+ @extend_schema(
153
+ methods=["post"],
154
+ request=EmptySerializer(),
155
+ responses={201: JobSerializer()},
156
+ )
157
+ @action(detail=True, methods=["post"])
158
+ def sync(self, request, pk):
159
+ if not request.user.has_perm("ipfabric_netbox.sync_ipfabricsource"):
160
+ raise PermissionDenied(
161
+ "This user does not have permission to sync IPFabricSource."
162
+ )
163
+ source = self.get_object()
164
+ if not source.ready_for_sync:
165
+ return HttpResponseBadRequest(
166
+ f"Source '{source.name}' is not ready to be synced."
167
+ )
168
+ job = source.enqueue_sync_job(request=request)
169
+ return Response(
170
+ JobSerializer(job, context={"request": request}).data, status=201
171
+ )
ipfabric_netbox/forms.py CHANGED
@@ -802,11 +802,7 @@ class IPFabricSyncForm(NetBoxModelForm):
802
802
  ]
803
803
  self.instance.parameters = parameters
804
804
  self.instance.status = DataSourceStatusChoices.NEW
805
-
806
- object = super().save(*args, **kwargs)
807
- if object.scheduled:
808
- object.enqueue_sync_job()
809
- return object
805
+ return super().save(*args, **kwargs)
810
806
 
811
807
 
812
808
  class IPFabricSyncBulkEditForm(NetBoxModelBulkEditForm):
ipfabric_netbox/jobs.py CHANGED
@@ -4,7 +4,6 @@ from datetime import timedelta
4
4
  from core.choices import DataSourceStatusChoices
5
5
  from core.choices import JobStatusChoices
6
6
  from core.exceptions import SyncError
7
- from core.models import Job
8
7
  from netbox.context_managers import event_tracking
9
8
  from rq.timeouts import JobTimeoutException
10
9
  from utilities.datetime import local_now
@@ -36,15 +35,15 @@ def sync_ipfabricsource(job, *args, **kwargs):
36
35
 
37
36
 
38
37
  def sync_ipfabric(job, *args, **kwargs):
39
- obj = IPFabricSync.objects.get(pk=job.object_id)
38
+ sync = IPFabricSync.objects.get(pk=job.object_id)
40
39
 
41
40
  try:
42
41
  job.start()
43
- obj.sync(job=job)
42
+ sync.sync(job=job)
44
43
  job.terminate()
45
44
  except Exception as e:
46
45
  job.terminate(status=JobStatusChoices.STATUS_ERRORED)
47
- IPFabricSync.objects.filter(pk=obj.pk).update(
46
+ IPFabricSync.objects.filter(pk=sync.pk).update(
48
47
  status=DataSourceStatusChoices.FAILED
49
48
  )
50
49
  if type(e) in (SyncError, JobTimeoutException):
@@ -52,15 +51,38 @@ def sync_ipfabric(job, *args, **kwargs):
52
51
  else:
53
52
  raise e
54
53
  finally:
55
- if obj.interval and not kwargs.get("adhoc"):
56
- new_scheduled_time = local_now() + timedelta(minutes=obj.interval)
57
- job = Job.enqueue(
58
- sync_ipfabric,
59
- name=f"{obj.name} - (scheduled)",
60
- instance=obj,
61
- user=obj.user,
62
- schedule_at=new_scheduled_time,
63
- interval=obj.interval,
54
+ if sync.interval and not kwargs.get("adhoc"):
55
+ new_scheduled_time = local_now() + timedelta(minutes=sync.interval)
56
+ # We want to create new Job only if scheduled time was before this Job started
57
+ # The current sync might have been changed while this job was running
58
+ sync.refresh_from_db()
59
+ if not sync.scheduled or (sync.scheduled and sync.scheduled > job.started):
60
+ logger.info(
61
+ f"Not scheduling a new job for IPFabricSync {sync.pk} as the scheduled time was changed while the job was running."
62
+ )
63
+ return
64
+ # Update the sync object with the new scheduled time
65
+ # This also triggers the creation of a new Job
66
+ # Running in fake request context to ensure user is set for changelog
67
+ request = NetBoxFakeRequest(
68
+ {
69
+ "META": {},
70
+ "POST": sync.parameters,
71
+ "GET": {},
72
+ "FILES": {},
73
+ "user": sync.user,
74
+ "path": "",
75
+ "id": job.job_id,
76
+ }
77
+ )
78
+
79
+ with event_tracking(request):
80
+ sync.scheduled = new_scheduled_time
81
+ sync.status = DataSourceStatusChoices.QUEUED
82
+ sync.full_clean()
83
+ sync.save()
84
+ logger.info(
85
+ f"Scheduled next sync for IPFabricSync {sync.pk} at {new_scheduled_time}."
64
86
  )
65
87
 
66
88
 
@@ -0,0 +1,70 @@
1
+ from datetime import timedelta
2
+ from typing import TYPE_CHECKING
3
+
4
+ from django.db import migrations
5
+ from utilities.datetime import local_now
6
+
7
+ if TYPE_CHECKING:
8
+ from django.apps import apps as apps_type
9
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
10
+
11
+
12
+ def clean_scheduled_jobs(
13
+ apps: "apps_type", schema_editor: "BaseDatabaseSchemaEditor"
14
+ ) -> None:
15
+ Job = apps.get_model("core", "Job")
16
+ IPFabricSync = apps.get_model("ipfabric_netbox", "IPFabricSync")
17
+ ObjectType = apps.get_model("core", "ObjectType")
18
+
19
+ for sync in IPFabricSync.objects.all():
20
+ try:
21
+ scheduled_jobs = Job.objects.filter(
22
+ object_id=sync.id,
23
+ object_type=ObjectType.objects.get_for_model(sync),
24
+ scheduled__isnull=False,
25
+ ).order_by("scheduled")
26
+ if not scheduled_jobs.exists():
27
+ continue
28
+ if not sync.scheduled:
29
+ # Delete all scheduled jobs if the sync is not scheduled
30
+ scheduled_jobs.delete()
31
+ continue
32
+ if scheduled_jobs.count() == 1:
33
+ # Only one scheduled job exists, let's update scheduled time on the sync object
34
+ # This does not create a new job since sync is a Faked object in migration
35
+ sync.scheduled = scheduled_jobs.first().scheduled
36
+ sync.full_clean()
37
+ sync.save()
38
+ continue
39
+ # More than one scheduled job exists
40
+ # Find the one that is closest to scheduled + N * interval
41
+ interval = timedelta(minutes=sync.interval)
42
+ elapsed = local_now() - sync.scheduled
43
+ intervals_passed = (elapsed // interval) + 1
44
+ closest_future_scheduled = sync.scheduled + intervals_passed * interval
45
+ closest_job = min(
46
+ scheduled_jobs,
47
+ key=lambda job: abs(job.scheduled - closest_future_scheduled),
48
+ )
49
+ for job in scheduled_jobs:
50
+ if job != closest_job:
51
+ job.delete()
52
+ sync.scheduled = closest_job.scheduled
53
+ sync.full_clean()
54
+ sync.save()
55
+ except Exception as err:
56
+ # Always be safe inside a migration
57
+ print(f"Error cleaning scheduled jobs for IPFabricSync {sync.id}: {err}")
58
+
59
+
60
+ class Migration(migrations.Migration):
61
+ dependencies = [
62
+ (
63
+ "ipfabric_netbox",
64
+ "0019_alter_ipfabrictransformmap_options_and_more",
65
+ ),
66
+ ]
67
+
68
+ operations = [
69
+ migrations.RunPython(clean_scheduled_jobs, migrations.RunPython.noop),
70
+ ]
ipfabric_netbox/models.py CHANGED
@@ -8,6 +8,7 @@ from uuid import uuid4
8
8
 
9
9
  import httpx
10
10
  from core.choices import DataSourceStatusChoices
11
+ from core.choices import JobStatusChoices
11
12
  from core.exceptions import SyncError
12
13
  from core.models import Job
13
14
  from core.models import ObjectType
@@ -669,6 +670,11 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
669
670
  def get_status_color(self):
670
671
  return DataSourceStatusChoices.colors.get(self.status)
671
672
 
673
+ def save(self, *args, **kwargs):
674
+ super().save(*args, **kwargs)
675
+ if self.scheduled:
676
+ self.enqueue_sync_job()
677
+
672
678
  @property
673
679
  def ready_for_sync(self):
674
680
  if self.status not in (DataSourceStatusChoices.SYNCING,):
@@ -705,32 +711,56 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
705
711
  pk__in=[tm.pk for tm in maps_by_target.values()]
706
712
  )
707
713
 
708
- def enqueue_sync_job(self, adhoc=False, user=None):
709
- # Set the status to "syncing"
710
- self.status = DataSourceStatusChoices.QUEUED
711
- IPFabricSync.objects.filter(pk=self.pk).update(status=self.status)
712
-
713
- Job.enqueue(
714
- import_string("ipfabric_netbox.jobs.sync_ipfabricsource"),
715
- name=f"{self.name} Snapshot Sync (Pre Ingestion)",
716
- instance=self.snapshot_data.source,
717
- user=self.user,
718
- )
714
+ def delete_scheduled_jobs(self) -> None:
715
+ Job.objects.filter(
716
+ object_type=ObjectType.objects.get_for_model(self),
717
+ object_id=self.pk,
718
+ status__in=[
719
+ JobStatusChoices.STATUS_PENDING,
720
+ JobStatusChoices.STATUS_SCHEDULED,
721
+ ],
722
+ ).delete()
723
+
724
+ def enqueue_sync_job(self, adhoc=False, user=None) -> Job | None:
725
+ def set_syncing_status():
726
+ self.status = DataSourceStatusChoices.QUEUED
727
+ IPFabricSync.objects.filter(pk=self.pk).update(status=self.status)
728
+
729
+ def sync_snapshots():
730
+ Job.enqueue(
731
+ import_string("ipfabric_netbox.jobs.sync_ipfabricsource"),
732
+ name=f"{self.name} Snapshot Sync (Pre Ingestion)",
733
+ instance=self.snapshot_data.source,
734
+ user=self.user,
735
+ )
719
736
 
720
737
  # Enqueue a sync job
721
738
  if not user:
722
739
  user = self.user
723
740
 
724
- if not adhoc and self.scheduled:
725
- job = Job.enqueue(
726
- import_string("ipfabric_netbox.jobs.sync_ipfabric"),
727
- name=f"{self.name} - (scheduled)",
728
- instance=self,
729
- user=self.user,
730
- schedule_at=self.scheduled,
731
- interval=self.interval,
732
- )
733
- elif adhoc:
741
+ if not adhoc:
742
+ if self.scheduled:
743
+ # We want to schedule a recurring Job
744
+ # We need to replace the old scheduled Job to make sure it has current context
745
+ self.delete_scheduled_jobs()
746
+ set_syncing_status()
747
+ sync_snapshots()
748
+ job = Job.enqueue(
749
+ import_string("ipfabric_netbox.jobs.sync_ipfabric"),
750
+ name=f"{self.name} - (scheduled)",
751
+ instance=self,
752
+ user=self.user,
753
+ schedule_at=self.scheduled,
754
+ interval=self.interval,
755
+ )
756
+ else:
757
+ # There should be no scheduled Job anymore, clean it up
758
+ self.delete_scheduled_jobs()
759
+ job = None
760
+ else:
761
+ # Start adhoc job immediately
762
+ set_syncing_status()
763
+ sync_snapshots()
734
764
  job = Job.enqueue(
735
765
  import_string("ipfabric_netbox.jobs.sync_ipfabric"),
736
766
  instance=self,
@@ -58,7 +58,7 @@
58
58
  </tr>
59
59
  <tr>
60
60
  <th scope="row">Schedule</th>
61
- <td><div>{{ object.scheduled }}</div></td>
61
+ <td><div>{% if scheduled_job %}<a href="{% url 'core:job' pk=scheduled_job.pk %}">{{ object.scheduled }}</a>{% else %} {{ object.scheduled | placeholder }} {% endif %}</div></td>
62
62
  </tr>
63
63
  <tr>
64
64
  <th scope="row">Interval</th>
@@ -1,3 +1,4 @@
1
+ from core.choices import DataSourceStatusChoices
1
2
  from django.contrib.contenttypes.models import ContentType
2
3
  from django.utils import timezone
3
4
  from rest_framework import status
@@ -15,7 +16,7 @@ from ipfabric_netbox.models import IPFabricTransformMap
15
16
  from ipfabric_netbox.models import IPFabricTransformMapGroup
16
17
 
17
18
 
18
- BASE = "/api/plugins/ipfabric/"
19
+ BASE = "/api/plugins/ipfabric"
19
20
 
20
21
 
21
22
  class IPFabricTransformMapGroupTest(APIViewTestCases.APIViewTestCase):
@@ -36,10 +37,10 @@ class IPFabricTransformMapGroupTest(APIViewTestCases.APIViewTestCase):
36
37
  }
37
38
 
38
39
  def _get_list_url(self):
39
- return f"{BASE}transform-map-group/"
40
+ return f"{BASE}/transform-map-group/"
40
41
 
41
42
  def _get_detail_url(self, instance):
42
- return f"{BASE}transform-map-group/{instance.pk}/"
43
+ return f"{BASE}/transform-map-group/{instance.pk}/"
43
44
 
44
45
  @classmethod
45
46
  def setUpTestData(cls):
@@ -62,10 +63,10 @@ class IPFabricTransformMapTest(APIViewTestCases.APIViewTestCase):
62
63
  ]
63
64
 
64
65
  def _get_list_url(self):
65
- return f"{BASE}transform-map/"
66
+ return f"{BASE}/transform-map/"
66
67
 
67
68
  def _get_detail_url(self, instance):
68
- return f"{BASE}transform-map/{instance.pk}/"
69
+ return f"{BASE}/transform-map/{instance.pk}/"
69
70
 
70
71
  @classmethod
71
72
  def setUpTestData(cls):
@@ -132,10 +133,10 @@ class IPFabricTransformFieldTest(APIViewTestCases.APIViewTestCase):
132
133
  ]
133
134
 
134
135
  def _get_list_url(self):
135
- return f"{BASE}transform-field/"
136
+ return f"{BASE}/transform-field/"
136
137
 
137
138
  def _get_detail_url(self, instance):
138
- return f"{BASE}transform-field/{instance.pk}/"
139
+ return f"{BASE}/transform-field/{instance.pk}/"
139
140
 
140
141
  @classmethod
141
142
  def setUpTestData(cls):
@@ -235,10 +236,10 @@ class IPFabricRelationshipFieldTest(APIViewTestCases.APIViewTestCase):
235
236
  ]
236
237
 
237
238
  def _get_list_url(self):
238
- return f"{BASE}relationship-field/"
239
+ return f"{BASE}/relationship-field/"
239
240
 
240
241
  def _get_detail_url(self, instance):
241
- return f"{BASE}relationship-field/{instance.pk}/"
242
+ return f"{BASE}/relationship-field/{instance.pk}/"
242
243
 
243
244
  @classmethod
244
245
  def setUpTestData(cls):
@@ -340,10 +341,10 @@ class IPFabricSourceTest(APIViewTestCases.APIViewTestCase):
340
341
  graphql_base_name = "ipfabric_source"
341
342
 
342
343
  def _get_list_url(self):
343
- return f"{BASE}source/"
344
+ return f"{BASE}/source/"
344
345
 
345
346
  def _get_detail_url(self, instance):
346
- return f"{BASE}source/{instance.pk}/"
347
+ return f"{BASE}/source/{instance.pk}/"
347
348
 
348
349
  @classmethod
349
350
  def setUpTestData(cls):
@@ -387,6 +388,66 @@ class IPFabricSourceTest(APIViewTestCases.APIViewTestCase):
387
388
  },
388
389
  ]
389
390
 
391
+ def test_sync_action_success(self):
392
+ """Test successful sync action with proper permissions and ready source."""
393
+ self.add_permissions(
394
+ "ipfabric_netbox.add_ipfabricsource",
395
+ "ipfabric_netbox.sync_ipfabricsource",
396
+ )
397
+ # Get the first source from setUpTestData
398
+ source = IPFabricSource.objects.first()
399
+ # Set status to make ready_for_sync return True
400
+ source.status = DataSourceStatusChoices.COMPLETED
401
+ source.save()
402
+
403
+ with self.settings(CELERY_TASK_ALWAYS_EAGER=True):
404
+ # Create a mock job object to simulate enqueue_sync_job response
405
+ from unittest.mock import Mock, patch
406
+
407
+ mock_job = Mock()
408
+ mock_job.id = "test-job-123"
409
+ mock_job.status = "queued"
410
+
411
+ with patch.object(source, "enqueue_sync_job", return_value=mock_job):
412
+ url = f"{BASE}/source/{source.pk}/sync/"
413
+ response = self.client.post(url, **self.header)
414
+
415
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
416
+ self.assertIn("id", response.data)
417
+
418
+ def test_sync_action_permission_denied(self):
419
+ """Test sync action without proper permissions."""
420
+ # Note: Not adding sync_ipfabricsource permission
421
+ self.add_permissions(
422
+ "ipfabric_netbox.add_ipfabricsource",
423
+ )
424
+
425
+ source = IPFabricSource.objects.first()
426
+ url = f"{BASE}/source/{source.pk}/sync/"
427
+ response = self.client.post(url, **self.header)
428
+
429
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
430
+
431
+ def test_sync_action_not_ready(self):
432
+ """Test sync action when source is not ready for sync."""
433
+ self.add_permissions(
434
+ "ipfabric_netbox.add_ipfabricsource",
435
+ "ipfabric_netbox.sync_ipfabricsource",
436
+ )
437
+
438
+ source = IPFabricSource.objects.first()
439
+ # Set status to make ready_for_sync return False
440
+ source.status = DataSourceStatusChoices.SYNCING
441
+ source.save()
442
+
443
+ url = f"{BASE}/source/{source.pk}/sync/"
444
+ response = self.client.post(url, **self.header)
445
+
446
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
447
+ self.assertIn(
448
+ f"Source '{source.name}' is not ready to be synced.", str(response.content)
449
+ )
450
+
390
451
 
391
452
  class IPFabricSnapshotTest(
392
453
  APIViewTestCases.GetObjectViewTestCase,
@@ -407,10 +468,10 @@ class IPFabricSnapshotTest(
407
468
  ]
408
469
 
409
470
  def _get_list_url(self):
410
- return f"{BASE}snapshot/"
471
+ return f"{BASE}/snapshot/"
411
472
 
412
473
  def _get_detail_url(self, instance):
413
- return f"{BASE}snapshot/{instance.pk}/"
474
+ return f"{BASE}/snapshot/{instance.pk}/"
414
475
 
415
476
  @classmethod
416
477
  def setUpTestData(cls):
@@ -468,7 +529,7 @@ class IPFabricSnapshotTest(
468
529
  def test_sites_action_lists_all_and_filters(self):
469
530
  self.add_permissions("ipfabric_netbox.view_ipfabricsnapshot")
470
531
  # list all
471
- url = f"{BASE}snapshot/{self.snapshots[0].pk}/sites/"
532
+ url = f"{BASE}/snapshot/{self.snapshots[0].pk}/sites/"
472
533
  resp = self.client.get(url, **self.header)
473
534
  self.assertHttpStatus(resp, status.HTTP_200_OK)
474
535
  body = resp.json()
@@ -477,7 +538,7 @@ class IPFabricSnapshotTest(
477
538
  labels = [i["name"] for i in body["results"]]
478
539
  self.assertEqual(labels, self.snapshots[0].data["sites"])
479
540
  # filter
480
- url = f"{BASE}snapshot/{self.snapshots[0].pk}/sites/?q=site"
541
+ url = f"{BASE}/snapshot/{self.snapshots[0].pk}/sites/?q=site"
481
542
  resp = self.client.get(url, **self.header)
482
543
  self.assertHttpStatus(resp, status.HTTP_200_OK)
483
544
  body = resp.json()
@@ -496,7 +557,7 @@ class IPFabricSnapshotTest(
496
557
  IPFabricData.objects.filter(snapshot_data=self.snapshots[0]).count(), 0
497
558
  )
498
559
  # PATCH raw
499
- url = f"{BASE}snapshot/{self.snapshots[0].pk}/raw/"
560
+ url = f"{BASE}/snapshot/{self.snapshots[0].pk}/raw/"
500
561
  payload = {
501
562
  "data": [
502
563
  {"data": {"example": 1}, "type": "device"},
@@ -550,10 +611,10 @@ class IPFabricSyncTest(APIViewTestCases.APIViewTestCase):
550
611
  }
551
612
 
552
613
  def _get_list_url(self):
553
- return f"{BASE}sync/"
614
+ return f"{BASE}/sync/"
554
615
 
555
616
  def _get_detail_url(self, instance):
556
- return f"{BASE}sync/{instance.pk}/"
617
+ return f"{BASE}/sync/{instance.pk}/"
557
618
 
558
619
  @classmethod
559
620
  def setUpTestData(cls):
@@ -637,6 +698,72 @@ class IPFabricSyncTest(APIViewTestCases.APIViewTestCase):
637
698
  cls.create_data[1]["parameters"] = {"ipaddress": True, "prefix": True}
638
699
  cls.create_data[2]["parameters"] = {"device": True, "interface": True}
639
700
 
701
+ def test_sync_action_success(self):
702
+ """Test successful sync action with proper permissions and ready sync."""
703
+ self.add_permissions(
704
+ "ipfabric_netbox.add_ipfabricsync",
705
+ "ipfabric_netbox.sync_ipfabricsync",
706
+ )
707
+ # Get the first sync from setUpTestData
708
+ sync = IPFabricSync.objects.first()
709
+ # Set status and ensure snapshot has data to make ready_for_sync return True
710
+ sync.status = DataSourceStatusChoices.COMPLETED
711
+ sync.save()
712
+
713
+ # Ensure the snapshot has data
714
+ sync.snapshot_data.source.type = (
715
+ "local" # For local type, ready_for_sync checks are simpler
716
+ )
717
+ sync.snapshot_data.source.save()
718
+
719
+ with self.settings(CELERY_TASK_ALWAYS_EAGER=True):
720
+ # Create a mock job object to simulate enqueue_sync_job response
721
+ from unittest.mock import Mock, patch
722
+
723
+ mock_job = Mock()
724
+ mock_job.id = "test-sync-job-456"
725
+ mock_job.status = "queued"
726
+
727
+ with patch.object(sync, "enqueue_sync_job", return_value=mock_job):
728
+ url = f"{BASE}/sync/{sync.pk}/sync/"
729
+ response = self.client.post(url, **self.header)
730
+
731
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
732
+ self.assertIn("id", response.data)
733
+
734
+ def test_sync_action_permission_denied(self):
735
+ """Test sync action without proper permissions."""
736
+ # Note: Not adding sync_ipfabricsource permission
737
+ self.add_permissions(
738
+ "ipfabric_netbox.add_ipfabricsync",
739
+ )
740
+
741
+ sync = IPFabricSync.objects.first()
742
+ url = f"{BASE}/sync/{sync.pk}/sync/"
743
+ response = self.client.post(url, **self.header)
744
+
745
+ self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
746
+
747
+ def test_sync_action_not_ready(self):
748
+ """Test sync action when sync is not ready for sync."""
749
+ self.add_permissions(
750
+ "ipfabric_netbox.add_ipfabricsync",
751
+ "ipfabric_netbox.sync_ipfabricsync",
752
+ )
753
+
754
+ sync = IPFabricSync.objects.first()
755
+ # Set status to make ready_for_sync return False
756
+ sync.status = DataSourceStatusChoices.SYNCING
757
+ sync.save()
758
+
759
+ url = f"{BASE}/sync/{sync.pk}/sync/"
760
+ response = self.client.post(url, **self.header)
761
+
762
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
763
+ self.assertIn(
764
+ f"Sync '{sync.name}' is not ready to be synced.", str(response.content)
765
+ )
766
+
640
767
 
641
768
  class IPFabricIngestionTest(
642
769
  APIViewTestCases.GetObjectViewTestCase,
@@ -653,10 +780,10 @@ class IPFabricIngestionTest(
653
780
  ]
654
781
 
655
782
  def _get_list_url(self):
656
- return f"{BASE}ingestion/"
783
+ return f"{BASE}/ingestion/"
657
784
 
658
785
  def _get_detail_url(self, instance):
659
- return f"{BASE}ingestion/{instance.pk}/"
786
+ return f"{BASE}/ingestion/{instance.pk}/"
660
787
 
661
788
  @classmethod
662
789
  def setUpTestData(cls):
@@ -754,10 +881,10 @@ class IPFabricIngestionIssueTest(
754
881
  ]
755
882
 
756
883
  def _get_list_url(self):
757
- return f"{BASE}ingestion-issues/"
884
+ return f"{BASE}/ingestion-issues/"
758
885
 
759
886
  def _get_detail_url(self, instance):
760
- return f"{BASE}ingestion-issues/{instance.pk}/"
887
+ return f"{BASE}/ingestion-issues/{instance.pk}/"
761
888
 
762
889
  @classmethod
763
890
  def setUpTestData(cls):
@@ -1,4 +1,5 @@
1
1
  import random
2
+ from datetime import timedelta
2
3
  from unittest.mock import patch
3
4
  from uuid import uuid4
4
5
 
@@ -611,7 +612,7 @@ class IPFabricSyncTestCase(
611
612
  # ViewTestCases.BulkImportObjectsViewTestCase,
612
613
  ):
613
614
  model = IPFabricSync
614
- user_permissions = ("ipfabric_netbox.start_ipfabricsync",)
615
+ user_permissions = ("ipfabric_netbox.sync_ipfabricsync",)
615
616
 
616
617
  @classmethod
617
618
  def setUpTestData(cls):
@@ -656,6 +657,8 @@ class IPFabricSyncTestCase(
656
657
  status=DataSourceStatusChoices.NEW,
657
658
  parameters=get_parameters(),
658
659
  last_synced=timezone.now(),
660
+ scheduled=timezone.now() + timedelta(hours=6),
661
+ interval=123456,
659
662
  ),
660
663
  IPFabricSync(
661
664
  name="Sync Job 2",
@@ -716,7 +719,7 @@ class IPFabricSyncTestCase(
716
719
 
717
720
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
718
721
  def test_get_htmx_request(self):
719
- instance = self._get_queryset().first()
722
+ instance = self._get_queryset().last()
720
723
  # Try GET with HTMX
721
724
  response = self.client.get(
722
725
  instance.get_absolute_url(), **{"HTTP_HX-Request": "true"}
ipfabric_netbox/views.py CHANGED
@@ -782,7 +782,15 @@ class IPFabricSyncView(generic.ObjectView):
782
782
 
783
783
  last_ingestion = instance.ipfabricingestion_set.last()
784
784
 
785
- return {"format": format, "last_ingestion": last_ingestion}
785
+ scheduled_job = None
786
+ if instance.scheduled:
787
+ scheduled_job = instance.jobs.filter(scheduled=instance.scheduled).last()
788
+
789
+ return {
790
+ "format": format,
791
+ "last_ingestion": last_ingestion,
792
+ "scheduled_job": scheduled_job,
793
+ }
786
794
 
787
795
 
788
796
  @register_model_view(IPFabricSync, "sync")
@@ -790,7 +798,7 @@ class IPFabricStartSyncView(BaseObjectView):
790
798
  queryset = IPFabricSync.objects.all()
791
799
 
792
800
  def get_required_permission(self):
793
- return "ipfabric_netbox.start_ipfabricsync"
801
+ return "ipfabric_netbox.sync_ipfabricsync"
794
802
 
795
803
  def get(self, request, pk):
796
804
  ipfabric = get_object_or_404(self.queryset, pk=pk)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipfabric_netbox
3
- Version: 4.3.0b6
3
+ Version: 4.3.0b8
4
4
  Summary: NetBox plugin to sync IP Fabric data into NetBox
5
5
  License: MIT
6
6
  Keywords: netbox,ipfabric,plugin,sync
@@ -1,19 +1,19 @@
1
- ipfabric_netbox/__init__.py,sha256=-W7W2QVW8zRl8VHGc4Pgv6uo7r0otVcpDjE3vuvPyFw,674
1
+ ipfabric_netbox/__init__.py,sha256=OTnSDhJRsShw8su9ERPqVv7GzWob31Rb6yfngwD91Mo,674
2
2
  ipfabric_netbox/api/__init__.py,sha256=XRclTGWVR0ZhAAwgYul5Wm_loug5_hUjEumbLQEwKYM,47
3
- ipfabric_netbox/api/serializers.py,sha256=7cmVsIzGzz9u6htLKizLr2Ar0OC7uV8rMX3U7EzRmG4,6482
3
+ ipfabric_netbox/api/serializers.py,sha256=lr_PWG0tqAxaKtkIIm8Gx2B-tn9yENQIfKY9cvu8Cco,6581
4
4
  ipfabric_netbox/api/urls.py,sha256=1fXXVTxNY5E64Nfz6b7zXD9bZI3FcefuxAWKMe0w_QU,1240
5
- ipfabric_netbox/api/views.py,sha256=gluK311fDx3sLsQ6iE6O1M_s3Kz1_sjD0sfmWR_UE2Q,5064
5
+ ipfabric_netbox/api/views.py,sha256=qOBTIzPtOBY75tTjirsTBbiRXrQQid478Tp15-WKbmQ,6859
6
6
  ipfabric_netbox/choices.py,sha256=gXRV_c04OnQl_kJTsAH1k88u8PWbiVXvcYwufZPKMdM,5035
7
7
  ipfabric_netbox/data/transform_map.json,sha256=4PsucgMHcLW3SPoKEptQCd0gA5tCF4hjrR4bGQFCWy8,21744
8
8
  ipfabric_netbox/exceptions.py,sha256=DT4dpbakvqoROtBR_F0LzvQCMNWpGhufFcUbZTx0OLY,2655
9
9
  ipfabric_netbox/filtersets.py,sha256=vaWlxf8DTwduv_aQ35kJxwyzmM1XvE781GjUj2z4QGQ,7845
10
- ipfabric_netbox/forms.py,sha256=nSEOlqPDvxgFuWVVmRf4AvubfMEAqh0ZlyyfoMQurCk,50024
10
+ ipfabric_netbox/forms.py,sha256=WI1OypOG4Zqry6jiH991ZBVBMnfwzUDhNJAZCUQNaUg,49932
11
11
  ipfabric_netbox/graphql/__init__.py,sha256=-a5w_VY7pc-RVt8MvThkTzeAqCC3xCan4Ue6iMefmjI,754
12
12
  ipfabric_netbox/graphql/enums.py,sha256=QFhwiwUKJekxQfsOGk_-70_WnkzrKEP_zIBMrin0S0Q,1343
13
13
  ipfabric_netbox/graphql/filters.py,sha256=B8xy9r9a18vWfV6a6tHXAN1FUcoxI6MOrbsdNmzusNI,12991
14
14
  ipfabric_netbox/graphql/schema.py,sha256=5UVHA1hHRvho5eLuuS-HLXTVTbxpUUx68ovG03gumGE,3209
15
15
  ipfabric_netbox/graphql/types.py,sha256=8RxdxiA-WnoaWSzh-tUJCuZBYGmd6QjfJiJcLirRMKY,5961
16
- ipfabric_netbox/jobs.py,sha256=KrTUeCuFUIU7vKCUS3RiBYCBG7g7GzhGagM_qFMGQJ4,3089
16
+ ipfabric_netbox/jobs.py,sha256=rFQuuc_6WVZlJ2EGDDj2ml14L4JeFneSSTiKA8N1SUg,4189
17
17
  ipfabric_netbox/migrations/0001_initial.py,sha256=VphxkWL6QzWq2tcrdXlog718xQtiEGizKwS830z_fOs,13824
18
18
  ipfabric_netbox/migrations/0001_initial_squashed_0013_switch_to_branching_plugin.py,sha256=OvofuA8ImeJmjrbtCrZPcRxAUWx2Ww4DUXLBZYsy6qE,21381
19
19
  ipfabric_netbox/migrations/0002_ipfabricsnapshot_status.py,sha256=xQpouHjOutyj6riN2B592njzSvz_icpkUbo5W7nWLYw,431
@@ -34,8 +34,9 @@ ipfabric_netbox/migrations/0016_tags_and_changelog_for_snapshots.py,sha256=XqftT
34
34
  ipfabric_netbox/migrations/0017_ipfabricsync_update_custom_fields.py,sha256=IVbAL2WdigYT40sXN0A8K3HweJ_O4QqyzjB06TbkG5E,447
35
35
  ipfabric_netbox/migrations/0018_remove_type_field.py,sha256=ffxW6IS3BLCbvM5M9DbDb_x6spMmRxnV1iq8IuXxMGw,385
36
36
  ipfabric_netbox/migrations/0019_alter_ipfabrictransformmap_options_and_more.py,sha256=ieDVedt9KpJBicAiC3kdZXzHeos12N0L9EdRXKmIVgY,501
37
+ ipfabric_netbox/migrations/0020_clean_scheduled_jobs.py,sha256=zjCVKnCWTKYYkpVRwHjqRIRR2j6ALSKXYMfraRjNu7Y,2652
37
38
  ipfabric_netbox/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
- ipfabric_netbox/models.py,sha256=czXxzdEWyVJKDEG1cK9tDvnoltD2guv42UboK0Cekis,36956
39
+ ipfabric_netbox/models.py,sha256=PgcxznpcihHwkdWlBJN1q2YshyvYS3lKvDvgQVSSDbI,38105
39
40
  ipfabric_netbox/navigation.py,sha256=2dEJ_wKHb52Tl0FOV1TH3JbxRe8YZ56ewrTsBFGKpCg,2210
40
41
  ipfabric_netbox/signals.py,sha256=cGa5PVD2i24pGXiVNfbu6ruIDqPVdwKQHTSWe9Ura84,1838
41
42
  ipfabric_netbox/tables.py,sha256=BpPmL6-KClX16Ol_PIADyOx9NtDR9oauXH5iO4GeSoI,9008
@@ -54,7 +55,7 @@ ipfabric_netbox/templates/ipfabric_netbox/ipfabric_table.html,sha256=TsF34lK2CyD
54
55
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricingestion.html,sha256=fm_X2FLnoTS6s6AL3WmU6p3puDojROSkPG0jA4EBQeM,4435
55
56
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricsnapshot.html,sha256=hj8ORs_4mM_xTjmw3McHN-da5seC8nbbkzobn0f1TSc,3482
56
57
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricsource.html,sha256=DQOA2TA7f1nI5YpxXthS1VzjIU1kMZus37l6bYSCauE,3869
57
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html,sha256=vgkxhJBWnfuZmDxfstLFJEAXc7FCY8Q7Hcu2MydI45A,4480
58
+ ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html,sha256=dt8HYuHCzIN4otLS9QK3e1aES14isFI-1jyp8jrENXU,4616
58
59
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap.html,sha256=qFo_Ku5oksx5co4HVtVq0xAVFI6CLWs-iBrwYzGsEGA,1460
59
60
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap_list.html,sha256=p8zqn0-B6mawSUM3zQrus6dsKUM5SRBTO0X94pLboX8,452
60
61
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap_restore.html,sha256=TV7gAZWtSd-c7mzOen_nv7Z8MZr2Vw8vkHP4zW9au4w,2580
@@ -71,17 +72,17 @@ ipfabric_netbox/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
71
72
  ipfabric_netbox/templatetags/ipfabric_netbox_helpers.py,sha256=STw4pAd2qG7hgf-O6UNTwsO5VqEa_gxf5wLv50BWL4Q,417
72
73
  ipfabric_netbox/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
74
  ipfabric_netbox/tests/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
- ipfabric_netbox/tests/api/test_api.py,sha256=-pW0xRqHUEr574HFL27C3FE77slI_bEh3Y5nfPDfcao,30283
75
+ ipfabric_netbox/tests/api/test_api.py,sha256=9o9k4kfnT3Zz9Hg0qnI0_XASt6pi5BCZQ3RhBjsb_iE,35215
75
76
  ipfabric_netbox/tests/test_forms.py,sha256=C8giV6E3PbMB9_864C12ebvfQ3Vlvdn39VIQQSP6GV8,61566
76
77
  ipfabric_netbox/tests/test_models.py,sha256=FFrIT5xxv_yvujKpxGjRJPNPBDF2Pqi8zbY0vxuJeQs,16043
77
- ipfabric_netbox/tests/test_views.py,sha256=ofKGbj3aSzl9l0CrdeLpcXBnCpVfsodKfYB9Q4L_F_c,87645
78
+ ipfabric_netbox/tests/test_views.py,sha256=pyUPdsgkvcfHC_BgmFtAQvENQfGvtEVSyNnRikLrlxc,87770
78
79
  ipfabric_netbox/urls.py,sha256=qF2BzZEDnPRd3opFaRfiMdaarYKFfv69iMaAbU2rsBU,2702
79
80
  ipfabric_netbox/utilities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
81
  ipfabric_netbox/utilities/ipfutils.py,sha256=wFmL5oriuF-is1ZlrIcLmoeYUY5ih-CA9weRQrx5AiA,31885
81
82
  ipfabric_netbox/utilities/logging.py,sha256=GYknjocMN6LQ2873_az3y0RKm29TCXaWviUIIneH-x0,3445
82
83
  ipfabric_netbox/utilities/nbutils.py,sha256=kFBEiJOGvr_49hJWCS2duXojx2-A9kVk0Xp_vj0ohfs,2641
83
84
  ipfabric_netbox/utilities/transform_map.py,sha256=GpM_7Mm6FE0qV2qbyj4YfDn0l-JkeeEHQOZkNVSSHk4,2391
84
- ipfabric_netbox/views.py,sha256=np1aYd9tBy7yERAhtwXMJvi2vDoXTkTx_ayLglw1LJ0,44521
85
- ipfabric_netbox-4.3.0b6.dist-info/METADATA,sha256=yIiT8CpHWwHj8ynUd48Q1YFpHwheTUaMtQ_aBKedL8I,4754
86
- ipfabric_netbox-4.3.0b6.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
87
- ipfabric_netbox-4.3.0b6.dist-info/RECORD,,
85
+ ipfabric_netbox/views.py,sha256=n3tTDs1xOAY5xtcMmhM6VrnpEr-2IzqeliFNgthQf1U,44746
86
+ ipfabric_netbox-4.3.0b8.dist-info/METADATA,sha256=A9F-rJ7Tl1pmXn__yVo7FfDRoZc1C03CWF8gv67JXzE,4754
87
+ ipfabric_netbox-4.3.0b8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
88
+ ipfabric_netbox-4.3.0b8.dist-info/RECORD,,