ipfabric_netbox 4.3.0b5__py3-none-any.whl → 4.3.0b7__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.0b5"
9
+ version = "4.3.0b7"
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
+ )
@@ -170,7 +170,7 @@ class IPFabricTransformMapSourceModelChoices(ChoiceSet):
170
170
  VRF = "vrf"
171
171
  PREFIX = "prefix"
172
172
  IPADDRESS = "ipaddress"
173
- # PARTNUMBERS = "part_number"
173
+ PARTNUMBERS = "part_number"
174
174
 
175
175
  CHOICES = (
176
176
  (SITE, "Site", "cyan"),
@@ -182,7 +182,7 @@ class IPFabricTransformMapSourceModelChoices(ChoiceSet):
182
182
  (VRF, "VRF", "gray"),
183
183
  (PREFIX, "Prefix", "gray"),
184
184
  (IPADDRESS, "IP Address", "gray"),
185
- # (PARTNUMBERS, "Part Number", "gray"),
185
+ (PARTNUMBERS, "Part Number", "gray"),
186
186
  )
187
187
 
188
188
 
ipfabric_netbox/forms.py CHANGED
@@ -7,8 +7,10 @@ from django.contrib.contenttypes.models import ContentType
7
7
  from django.core.exceptions import ValidationError
8
8
  from django.utils import timezone
9
9
  from django.utils.translation import gettext_lazy as _
10
+ from netbox.forms import NetBoxModelBulkEditForm
10
11
  from netbox.forms import NetBoxModelFilterSetForm
11
12
  from netbox.forms import NetBoxModelForm
13
+ from netbox.forms import NetBoxModelImportForm
12
14
  from netbox.forms.mixins import SavedFiltersMixin
13
15
  from utilities.datetime import local_now
14
16
  from utilities.forms import add_blank_choice
@@ -16,21 +18,28 @@ from utilities.forms import ConfirmationForm
16
18
  from utilities.forms import FilterForm
17
19
  from utilities.forms import get_field_value
18
20
  from utilities.forms.fields import CommentField
21
+ from utilities.forms.fields import CSVChoiceField
22
+ from utilities.forms.fields import CSVContentTypeField
23
+ from utilities.forms.fields import CSVModelChoiceField
19
24
  from utilities.forms.fields import DynamicModelChoiceField
20
25
  from utilities.forms.fields import DynamicModelMultipleChoiceField
21
26
  from utilities.forms.rendering import FieldSet
22
27
  from utilities.forms.widgets import APISelectMultiple
28
+ from utilities.forms.widgets import BulkEditNullBooleanSelect
23
29
  from utilities.forms.widgets import DateTimePicker
24
30
  from utilities.forms.widgets import HTMXSelect
25
31
  from utilities.forms.widgets import NumberWithOptions
26
32
 
27
33
  from .choices import IPFabricSnapshotStatusModelChoices
34
+ from .choices import IPFabricSourceTypeChoices
35
+ from .choices import IPFabricTransformMapSourceModelChoices
28
36
  from .choices import required_transform_map_contenttypes
29
37
  from .choices import transform_field_source_columns
30
38
  from .models import IPFabricIngestion
31
39
  from .models import IPFabricRelationshipField
32
40
  from .models import IPFabricSnapshot
33
41
  from .models import IPFabricSource
42
+ from .models import IPFabricSupportedSyncModels
34
43
  from .models import IPFabricSync
35
44
  from .models import IPFabricTransformField
36
45
  from .models import IPFabricTransformMap
@@ -269,6 +278,20 @@ class IPFabricTransformMapGroupForm(NetBoxModelForm):
269
278
  fields = ("name", "description")
270
279
 
271
280
 
281
+ class IPFabricTransformMapGroupBulkEditForm(NetBoxModelBulkEditForm):
282
+ description = forms.CharField(
283
+ label=_("Description"), max_length=200, required=False
284
+ )
285
+ model = IPFabricTransformMapGroup
286
+ fields = ("description",)
287
+
288
+
289
+ class IPFabricTransformMapGroupBulkImportForm(NetBoxModelImportForm):
290
+ class Meta:
291
+ model = IPFabricTransformMapGroup
292
+ fields = ("name", "description")
293
+
294
+
272
295
  class IPFabricTransformMapForm(NetBoxModelForm):
273
296
  class Meta:
274
297
  model = IPFabricTransformMap
@@ -278,6 +301,44 @@ class IPFabricTransformMapForm(NetBoxModelForm):
278
301
  }
279
302
 
280
303
 
304
+ class IPFabricTransformMapBulkEditForm(NetBoxModelBulkEditForm):
305
+ group = forms.ModelChoiceField(
306
+ queryset=IPFabricTransformMapGroup.objects.all(),
307
+ required=False,
308
+ label="Target Group",
309
+ )
310
+ model = IPFabricTransformMap
311
+ fields = ("group",)
312
+ nullable_fields = ("group",)
313
+
314
+
315
+ class IPFabricTransformMapBulkImportForm(NetBoxModelImportForm):
316
+ source_model = CSVChoiceField(
317
+ label=_("Source model"),
318
+ choices=IPFabricTransformMapSourceModelChoices,
319
+ help_text=_("Models available in IP Fabric to source data from"),
320
+ )
321
+ target_model = CSVContentTypeField(
322
+ queryset=ContentType.objects.filter(IPFabricSupportedSyncModels),
323
+ required=True,
324
+ label=_("Target model"),
325
+ help_text=_(
326
+ "Target model to apply this transform map to (use format 'app_label.model', e.g., 'dcim.device')"
327
+ ),
328
+ )
329
+ group = CSVModelChoiceField(
330
+ label=_("Group"),
331
+ queryset=IPFabricTransformMapGroup.objects.all(),
332
+ required=False,
333
+ to_field_name="name",
334
+ help_text=_("Name of assigned transform map group"),
335
+ )
336
+
337
+ class Meta:
338
+ model = IPFabricTransformMap
339
+ fields = ("name", "source_model", "target_model", "group")
340
+
341
+
281
342
  class IPFabricTransformMapCloneForm(forms.Form):
282
343
  name = forms.CharField(
283
344
  required=True, label="Name", help_text="Name for the cloned transform map."
@@ -439,6 +500,23 @@ class IPFabricSourceForm(NetBoxModelForm):
439
500
  return instance
440
501
 
441
502
 
503
+ class IPFabricSourceBulkEditForm(NetBoxModelBulkEditForm):
504
+ comments = CommentField()
505
+ type = forms.ChoiceField(
506
+ choices=add_blank_choice(IPFabricSourceTypeChoices),
507
+ required=False,
508
+ initial="",
509
+ )
510
+
511
+ model = IPFabricSource
512
+ fields = (
513
+ "type",
514
+ "url",
515
+ "description",
516
+ "comments",
517
+ )
518
+
519
+
442
520
  class OrderedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
443
521
  """A ModelMultipleChoiceField that preserves the order of the selected items."""
444
522
 
@@ -724,11 +802,66 @@ class IPFabricSyncForm(NetBoxModelForm):
724
802
  ]
725
803
  self.instance.parameters = parameters
726
804
  self.instance.status = DataSourceStatusChoices.NEW
805
+ return super().save(*args, **kwargs)
806
+
727
807
 
728
- object = super().save(*args, **kwargs)
729
- if object.scheduled:
730
- object.enqueue_sync_job()
731
- return object
808
+ class IPFabricSyncBulkEditForm(NetBoxModelBulkEditForm):
809
+ source = forms.ModelChoiceField(
810
+ queryset=IPFabricSource.objects.all(),
811
+ required=False,
812
+ label=_("IP Fabric Source"),
813
+ )
814
+
815
+ snapshot_data = DynamicModelChoiceField(
816
+ queryset=IPFabricSnapshot.objects.filter(status="loaded"),
817
+ required=False,
818
+ label=_("Snapshot"),
819
+ query_params={
820
+ "source_id": "$source",
821
+ "status": "loaded",
822
+ },
823
+ )
824
+
825
+ update_custom_fields = forms.NullBooleanField(
826
+ required=False,
827
+ label=_("Custom Fields Updating"),
828
+ help_text=_("Update object custom fields where applicable."),
829
+ widget=BulkEditNullBooleanSelect,
830
+ )
831
+
832
+ scheduled = forms.DateTimeField(
833
+ required=False,
834
+ widget=DateTimePicker(),
835
+ label=_("Schedule at"),
836
+ help_text=_("Schedule execution of sync to a set time"),
837
+ )
838
+
839
+ interval = forms.IntegerField(
840
+ required=False,
841
+ min_value=1,
842
+ label=_("Recurs every"),
843
+ widget=NumberWithOptions(options=JobIntervalChoices),
844
+ help_text=_("Interval at which this sync is re-run (in minutes)"),
845
+ )
846
+
847
+ auto_merge = forms.NullBooleanField(
848
+ required=False,
849
+ label=_("Auto Merge"),
850
+ help_text=_("Automatically merge staged changes into NetBox"),
851
+ widget=BulkEditNullBooleanSelect,
852
+ )
853
+
854
+ model = IPFabricSync
855
+ fields = (
856
+ "name",
857
+ "source",
858
+ "snapshot_data",
859
+ "auto_merge",
860
+ "update_custom_fields",
861
+ "tags",
862
+ "scheduled",
863
+ "interval",
864
+ )
732
865
 
733
866
 
734
867
  tableChoices = [
ipfabric_netbox/models.py CHANGED
@@ -167,7 +167,7 @@ class IPFabricTransformMap(NetBoxModel):
167
167
  qs = qs.exclude(pk=self.pk)
168
168
  if qs.exists():
169
169
  err_msg = _(
170
- "A transform map with this group and target model already exists."
170
+ f"A transform map with group '{self.group}' and target model '{self.target_model}' already exists."
171
171
  )
172
172
  raise ValidationError(
173
173
  {
@@ -669,6 +669,11 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
669
669
  def get_status_color(self):
670
670
  return DataSourceStatusChoices.colors.get(self.status)
671
671
 
672
+ def save(self, *args, **kwargs):
673
+ super().save(*args, **kwargs)
674
+ if self.scheduled:
675
+ self.enqueue_sync_job()
676
+
672
677
  @property
673
678
  def ready_for_sync(self):
674
679
  if self.status not in (DataSourceStatusChoices.SYNCING,):
ipfabric_netbox/tables.py CHANGED
@@ -45,6 +45,7 @@ DATA_BUTTON = """
45
45
 
46
46
  class IPFabricRelationshipFieldTable(NetBoxTable):
47
47
  actions = columns.ActionsColumn(actions=("edit", "delete"))
48
+ source_model = columns.ContentTypeColumn(verbose_name=_("Source Model"))
48
49
 
49
50
  class Meta(NetBoxTable.Meta):
50
51
  model = IPFabricRelationshipField
@@ -79,6 +80,7 @@ class IPFabricTransformMapGroupTable(NetBoxTable):
79
80
  class IPFabricTransformMapTable(NetBoxTable):
80
81
  name = tables.Column(linkify=True)
81
82
  group = tables.Column(linkify=True)
83
+ target_model = columns.ContentTypeColumn(verbose_name=_("Target Model"))
82
84
 
83
85
  class Meta(NetBoxTable.Meta):
84
86
  model = IPFabricTransformMap
@@ -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):