ipfabric_netbox 4.3.2b9__py3-none-any.whl → 4.3.2b11__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.

Files changed (50) hide show
  1. ipfabric_netbox/__init__.py +1 -1
  2. ipfabric_netbox/api/serializers.py +112 -7
  3. ipfabric_netbox/api/urls.py +6 -0
  4. ipfabric_netbox/api/views.py +23 -0
  5. ipfabric_netbox/choices.py +74 -40
  6. ipfabric_netbox/data/endpoint.json +52 -0
  7. ipfabric_netbox/data/filters.json +51 -0
  8. ipfabric_netbox/data/transform_map.json +190 -176
  9. ipfabric_netbox/exceptions.py +7 -5
  10. ipfabric_netbox/filtersets.py +310 -41
  11. ipfabric_netbox/forms.py +330 -80
  12. ipfabric_netbox/graphql/__init__.py +6 -0
  13. ipfabric_netbox/graphql/enums.py +5 -5
  14. ipfabric_netbox/graphql/filters.py +56 -4
  15. ipfabric_netbox/graphql/schema.py +28 -0
  16. ipfabric_netbox/graphql/types.py +61 -1
  17. ipfabric_netbox/jobs.py +12 -1
  18. ipfabric_netbox/migrations/0022_prepare_for_filters.py +182 -0
  19. ipfabric_netbox/migrations/0023_populate_filters_data.py +303 -0
  20. ipfabric_netbox/migrations/0024_finish_filters.py +29 -0
  21. ipfabric_netbox/migrations/0025_add_vss_chassis_endpoint.py +166 -0
  22. ipfabric_netbox/models.py +432 -17
  23. ipfabric_netbox/navigation.py +98 -24
  24. ipfabric_netbox/tables.py +194 -9
  25. ipfabric_netbox/templates/ipfabric_netbox/htmx_list.html +5 -0
  26. ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions.html +59 -0
  27. ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions_content.html +39 -0
  28. ipfabric_netbox/templates/ipfabric_netbox/inc/endpoint_filters_with_selector.html +54 -0
  29. ipfabric_netbox/templates/ipfabric_netbox/ipfabricendpoint.html +39 -0
  30. ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilter.html +51 -0
  31. ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression.html +39 -0
  32. ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression_edit.html +150 -0
  33. ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html +1 -1
  34. ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap.html +16 -2
  35. ipfabric_netbox/templatetags/ipfabric_netbox_helpers.py +68 -0
  36. ipfabric_netbox/tests/api/test_api.py +333 -13
  37. ipfabric_netbox/tests/test_filtersets.py +2592 -0
  38. ipfabric_netbox/tests/test_forms.py +1349 -74
  39. ipfabric_netbox/tests/test_models.py +242 -34
  40. ipfabric_netbox/tests/test_views.py +2031 -26
  41. ipfabric_netbox/urls.py +35 -0
  42. ipfabric_netbox/utilities/endpoint.py +83 -0
  43. ipfabric_netbox/utilities/filters.py +88 -0
  44. ipfabric_netbox/utilities/ipfutils.py +393 -377
  45. ipfabric_netbox/utilities/logging.py +7 -7
  46. ipfabric_netbox/utilities/transform_map.py +144 -5
  47. ipfabric_netbox/views.py +719 -5
  48. {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/METADATA +2 -2
  49. {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/RECORD +50 -33
  50. {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/WHEEL +1 -1
@@ -1,16 +1,25 @@
1
+ import json
1
2
  from datetime import timedelta
2
3
  from unittest.mock import patch
3
4
 
4
5
  from dcim.models import Device
6
+ from dcim.models import Interface
5
7
  from dcim.models import Site
8
+ from django import forms
6
9
  from django.contrib.contenttypes.models import ContentType
7
10
  from django.test import TestCase
11
+ from ipam.models import IPAddress
12
+ from ipam.models import VRF
8
13
  from utilities.datetime import local_now
14
+ from utilities.forms.rendering import FieldSet
9
15
 
16
+ from ipfabric_netbox.choices import IPFabricFilterTypeChoices
10
17
  from ipfabric_netbox.choices import IPFabricSnapshotStatusModelChoices
11
18
  from ipfabric_netbox.choices import IPFabricSourceStatusChoices
12
19
  from ipfabric_netbox.choices import IPFabricSourceTypeChoices
13
20
  from ipfabric_netbox.choices import IPFabricSyncStatusChoices
21
+ from ipfabric_netbox.forms import IPFabricFilterExpressionForm
22
+ from ipfabric_netbox.forms import IPFabricFilterForm
14
23
  from ipfabric_netbox.forms import IPFabricIngestionFilterForm
15
24
  from ipfabric_netbox.forms import IPFabricIngestionMergeForm
16
25
  from ipfabric_netbox.forms import IPFabricRelationshipFieldForm
@@ -22,6 +31,9 @@ from ipfabric_netbox.forms import IPFabricTransformFieldForm
22
31
  from ipfabric_netbox.forms import IPFabricTransformMapCloneForm
23
32
  from ipfabric_netbox.forms import IPFabricTransformMapForm
24
33
  from ipfabric_netbox.forms import IPFabricTransformMapGroupForm
34
+ from ipfabric_netbox.models import IPFabricEndpoint
35
+ from ipfabric_netbox.models import IPFabricFilter
36
+ from ipfabric_netbox.models import IPFabricFilterExpression
25
37
  from ipfabric_netbox.models import IPFabricRelationshipField
26
38
  from ipfabric_netbox.models import IPFabricSnapshot
27
39
  from ipfabric_netbox.models import IPFabricSource
@@ -148,7 +160,6 @@ class IPFabricSourceFormTestCase(TestCase):
148
160
 
149
161
  def test_remote_source_creates_last_snapshot(self):
150
162
  """Check that $last snapshot is created for remote sources"""
151
- from ipfabric_netbox.models import IPFabricSnapshot
152
163
 
153
164
  self.assertEqual(IPFabricSnapshot.objects.count(), 0)
154
165
 
@@ -350,10 +361,14 @@ class IPFabricRelationshipFieldFormTestCase(TestCase):
350
361
  cls.device_content_type = ContentType.objects.get_for_model(Device)
351
362
  cls.site_content_type = ContentType.objects.get_for_model(Site)
352
363
 
364
+ cls.device_endpoint = IPFabricEndpoint.objects.get(
365
+ endpoint="/inventory/devices"
366
+ )
367
+
353
368
  cls.transform_map = IPFabricTransformMap.objects.create(
354
369
  name="Test Transform Map",
355
370
  group=cls.transform_map_group,
356
- source_model="device",
371
+ source_endpoint=cls.device_endpoint,
357
372
  target_model=cls.device_content_type,
358
373
  )
359
374
 
@@ -463,10 +478,14 @@ class IPFabricTransformFieldFormTestCase(TestCase):
463
478
 
464
479
  cls.device_content_type = ContentType.objects.get_for_model(Device)
465
480
 
481
+ cls.device_endpoint = IPFabricEndpoint.objects.get(
482
+ endpoint="/inventory/devices"
483
+ )
484
+
466
485
  cls.transform_map = IPFabricTransformMap.objects.create(
467
486
  name="Test Transform Map",
468
487
  group=cls.transform_map_group,
469
- source_model="device",
488
+ source_endpoint=cls.device_endpoint,
470
489
  target_model=cls.device_content_type,
471
490
  )
472
491
 
@@ -587,12 +606,15 @@ class IPFabricTransformMapFormTestCase(TestCase):
587
606
  name="Test Group", description="Test group description"
588
607
  )
589
608
  cls.device_content_type = ContentType.objects.get_for_model(Device)
609
+ cls.device_endpoint = IPFabricEndpoint.objects.get(
610
+ endpoint="/inventory/devices"
611
+ )
590
612
 
591
613
  def test_fields_are_required(self):
592
614
  form = IPFabricTransformMapForm(data={})
593
615
  self.assertFalse(form.is_valid(), form.errors)
594
616
  self.assertIn("name", form.errors)
595
- self.assertIn("source_model", form.errors)
617
+ self.assertIn("source_endpoint", form.errors)
596
618
  self.assertIn("target_model", form.errors)
597
619
 
598
620
  def test_group_is_optional(self):
@@ -603,7 +625,7 @@ class IPFabricTransformMapFormTestCase(TestCase):
603
625
  form = IPFabricTransformMapForm(
604
626
  data={
605
627
  "name": "Test Transform Map",
606
- "source_model": "device",
628
+ "source_endpoint": self.device_endpoint.pk,
607
629
  "target_model": self.device_content_type.pk,
608
630
  }
609
631
  )
@@ -614,7 +636,7 @@ class IPFabricTransformMapFormTestCase(TestCase):
614
636
  data={
615
637
  "name": "Test Transform Map",
616
638
  "group": self.transform_map_group.pk,
617
- "source_model": "device",
639
+ "source_endpoint": self.device_endpoint.pk,
618
640
  "target_model": self.device_content_type.pk,
619
641
  }
620
642
  )
@@ -623,6 +645,301 @@ class IPFabricTransformMapFormTestCase(TestCase):
623
645
  self.assertEqual(instance.name, "Test Transform Map")
624
646
  self.assertEqual(instance.group, self.transform_map_group)
625
647
 
648
+ def test_existing_instance_excludes_self_from_parents_choices(self):
649
+ """Test that when editing an existing transform map, it excludes itself from parents choices"""
650
+ # Create a transform map instance
651
+ transform_map = IPFabricTransformMap.objects.create(
652
+ name="Test Transform Map",
653
+ group=self.transform_map_group,
654
+ source_endpoint=self.device_endpoint,
655
+ target_model=self.device_content_type,
656
+ )
657
+
658
+ # Create another transform map that could be a parent
659
+ other_transform_map = IPFabricTransformMap.objects.create(
660
+ name="Other Transform Map",
661
+ group=self.transform_map_group,
662
+ source_endpoint=self.device_endpoint,
663
+ target_model=ContentType.objects.get_for_model(Site),
664
+ )
665
+
666
+ # Initialize form with the existing instance
667
+ form = IPFabricTransformMapForm(instance=transform_map)
668
+
669
+ # Verify that the instance itself is excluded from parents choices
670
+ parents_queryset = form.fields["parents"].queryset
671
+ self.assertNotIn(transform_map, parents_queryset)
672
+
673
+ # Verify that other transform maps are still available as parent options
674
+ self.assertIn(other_transform_map, parents_queryset)
675
+
676
+ # Verify help text is set
677
+ self.assertIn(
678
+ "must be processed before this one", form.fields["parents"].help_text
679
+ )
680
+
681
+ def test_circular_dependency_direct(self):
682
+ """Test that direct circular dependencies are detected (A → B, B → A)"""
683
+ # Create two transform maps
684
+ site_ct = ContentType.objects.get_for_model(Site)
685
+
686
+ tm_a = IPFabricTransformMap.objects.create(
687
+ name="Transform Map A",
688
+ group=self.transform_map_group,
689
+ source_endpoint=self.device_endpoint,
690
+ target_model=self.device_content_type,
691
+ )
692
+
693
+ tm_b = IPFabricTransformMap.objects.create(
694
+ name="Transform Map B",
695
+ group=self.transform_map_group,
696
+ source_endpoint=self.device_endpoint,
697
+ target_model=site_ct,
698
+ )
699
+
700
+ # Set B as parent of A
701
+ tm_a.parents.add(tm_b)
702
+
703
+ # Try to set A as parent of B (should fail)
704
+ form = IPFabricTransformMapForm(
705
+ instance=tm_b,
706
+ data={
707
+ "name": tm_b.name,
708
+ "group": self.transform_map_group.pk,
709
+ "source_endpoint": self.device_endpoint.pk,
710
+ "target_model": site_ct.pk,
711
+ "parents": [tm_a.pk],
712
+ },
713
+ )
714
+
715
+ self.assertFalse(form.is_valid())
716
+ self.assertIn("parents", form.errors)
717
+ self.assertIn("circular dependency", str(form.errors["parents"]).lower())
718
+
719
+ def test_circular_dependency_indirect(self):
720
+ """Test that indirect circular dependencies are detected (A → B → C → A)"""
721
+ # Create three transform maps with different content types
722
+
723
+ site_ct = ContentType.objects.get_for_model(Site)
724
+ vrf_ct = ContentType.objects.get_for_model(VRF)
725
+ interface_ct = ContentType.objects.get_for_model(Interface)
726
+
727
+ vrf_endpoint = IPFabricEndpoint.objects.get(
728
+ endpoint="/technology/routing/vrf/detail"
729
+ )
730
+ interface_endpoint = IPFabricEndpoint.objects.get(
731
+ endpoint="/inventory/interfaces"
732
+ )
733
+ site_endpoint = IPFabricEndpoint.objects.get(
734
+ endpoint="/inventory/sites/overview"
735
+ )
736
+
737
+ tm_a = IPFabricTransformMap.objects.create(
738
+ name="Transform Map A",
739
+ group=self.transform_map_group,
740
+ source_endpoint=site_endpoint,
741
+ target_model=site_ct,
742
+ )
743
+
744
+ tm_b = IPFabricTransformMap.objects.create(
745
+ name="Transform Map B",
746
+ group=self.transform_map_group,
747
+ source_endpoint=vrf_endpoint,
748
+ target_model=vrf_ct,
749
+ )
750
+
751
+ tm_c = IPFabricTransformMap.objects.create(
752
+ name="Transform Map C",
753
+ group=self.transform_map_group,
754
+ source_endpoint=interface_endpoint,
755
+ target_model=interface_ct,
756
+ )
757
+
758
+ # Create chain: A → B → C
759
+ tm_b.parents.add(tm_a)
760
+ tm_c.parents.add(tm_b)
761
+
762
+ # Try to add C as parent of A (completing the cycle: A → B → C → A)
763
+ form = IPFabricTransformMapForm(
764
+ instance=tm_a,
765
+ data={
766
+ "name": tm_a.name,
767
+ "group": self.transform_map_group.pk,
768
+ "source_endpoint": site_endpoint.pk,
769
+ "target_model": site_ct.pk,
770
+ "parents": [tm_c.pk],
771
+ },
772
+ )
773
+
774
+ self.assertFalse(form.is_valid())
775
+ self.assertIn("parents", form.errors)
776
+ self.assertIn("circular dependency", str(form.errors["parents"]).lower())
777
+
778
+ def test_valid_parent_assignment_no_cycle(self):
779
+ """Test that valid parent assignments without cycles are allowed"""
780
+ site_ct = ContentType.objects.get_for_model(Site)
781
+ vrf_ct = ContentType.objects.get_for_model(VRF)
782
+
783
+ vrf_endpoint = IPFabricEndpoint.objects.get(
784
+ endpoint="/technology/routing/vrf/detail"
785
+ )
786
+ site_endpoint = IPFabricEndpoint.objects.get(
787
+ endpoint="/inventory/sites/overview"
788
+ )
789
+
790
+ tm_site = IPFabricTransformMap.objects.create(
791
+ name="Site Transform Map",
792
+ group=self.transform_map_group,
793
+ source_endpoint=site_endpoint,
794
+ target_model=site_ct,
795
+ )
796
+
797
+ tm_vrf = IPFabricTransformMap.objects.create(
798
+ name="VRF Transform Map",
799
+ group=self.transform_map_group,
800
+ source_endpoint=vrf_endpoint,
801
+ target_model=vrf_ct,
802
+ )
803
+
804
+ # Set Site as parent of VRF (valid - no cycle)
805
+ form = IPFabricTransformMapForm(
806
+ instance=tm_vrf,
807
+ data={
808
+ "name": tm_vrf.name,
809
+ "group": self.transform_map_group.pk,
810
+ "source_endpoint": vrf_endpoint.pk,
811
+ "target_model": vrf_ct.pk,
812
+ "parents": [tm_site.pk],
813
+ },
814
+ )
815
+
816
+ self.assertTrue(form.is_valid(), form.errors)
817
+ form.save()
818
+ self.assertIn(tm_site, tm_vrf.parents.all())
819
+
820
+ def test_multiple_parents_no_cycle(self):
821
+ """Test that multiple parents can be assigned without creating cycles"""
822
+
823
+ site_ct = ContentType.objects.get_for_model(Site)
824
+ vrf_ct = ContentType.objects.get_for_model(VRF)
825
+ interface_ct = ContentType.objects.get_for_model(Interface)
826
+ ipaddress_ct = ContentType.objects.get_for_model(IPAddress)
827
+
828
+ vrf_endpoint = IPFabricEndpoint.objects.get(
829
+ endpoint="/technology/routing/vrf/detail"
830
+ )
831
+ interface_endpoint = IPFabricEndpoint.objects.get(
832
+ endpoint="/inventory/interfaces"
833
+ )
834
+ site_endpoint = IPFabricEndpoint.objects.get(
835
+ endpoint="/inventory/sites/overview"
836
+ )
837
+ ipaddress_endpoint = IPFabricEndpoint.objects.get(
838
+ endpoint="/technology/addressing/managed-ip/ipv4"
839
+ )
840
+
841
+ tm_site = IPFabricTransformMap.objects.create(
842
+ name="Site Transform Map",
843
+ group=self.transform_map_group,
844
+ source_endpoint=site_endpoint,
845
+ target_model=site_ct,
846
+ )
847
+
848
+ tm_vrf = IPFabricTransformMap.objects.create(
849
+ name="VRF Transform Map",
850
+ group=self.transform_map_group,
851
+ source_endpoint=vrf_endpoint,
852
+ target_model=vrf_ct,
853
+ )
854
+
855
+ tm_interface = IPFabricTransformMap.objects.create(
856
+ name="Interface Transform Map",
857
+ group=self.transform_map_group,
858
+ source_endpoint=interface_endpoint,
859
+ target_model=interface_ct,
860
+ )
861
+
862
+ tm_ipaddress = IPFabricTransformMap.objects.create(
863
+ name="IP Address Transform Map",
864
+ group=self.transform_map_group,
865
+ source_endpoint=ipaddress_endpoint,
866
+ target_model=ipaddress_ct,
867
+ )
868
+
869
+ # Set Site as parent of both VRF and Interface
870
+ tm_vrf.parents.add(tm_site)
871
+ tm_interface.parents.add(tm_site)
872
+
873
+ # Set both VRF and Interface as parents of IP Address (valid - no cycle)
874
+ form = IPFabricTransformMapForm(
875
+ instance=tm_ipaddress,
876
+ data={
877
+ "name": tm_ipaddress.name,
878
+ "group": self.transform_map_group.pk,
879
+ "source_endpoint": ipaddress_endpoint.pk,
880
+ "target_model": ipaddress_ct.pk,
881
+ "parents": [tm_vrf.pk, tm_interface.pk],
882
+ },
883
+ )
884
+
885
+ self.assertTrue(form.is_valid(), form.errors)
886
+ form.save()
887
+ self.assertEqual(tm_ipaddress.parents.count(), 2)
888
+ self.assertIn(tm_vrf, tm_ipaddress.parents.all())
889
+ self.assertIn(tm_interface, tm_ipaddress.parents.all())
890
+
891
+ def test_self_as_parent_prevented_by_form_init(self):
892
+ """Test that a transform map cannot be set as its own parent (prevented by form __init__)"""
893
+ tm = IPFabricTransformMap.objects.create(
894
+ name="Test Transform Map",
895
+ group=self.transform_map_group,
896
+ source_endpoint=self.device_endpoint,
897
+ target_model=self.device_content_type,
898
+ )
899
+
900
+ # The form should exclude self from parents queryset
901
+ form = IPFabricTransformMapForm(instance=tm)
902
+ parents_queryset = form.fields["parents"].queryset
903
+ self.assertNotIn(tm, parents_queryset)
904
+
905
+ def test_no_validation_for_new_instances(self):
906
+ """Test that circular dependency validation is skipped for new instances"""
907
+ # New instance (no pk) should not trigger circular dependency validation
908
+ form = IPFabricTransformMapForm(
909
+ data={
910
+ "name": "New Transform Map",
911
+ "group": self.transform_map_group.pk,
912
+ "source_endpoint": self.device_endpoint.pk,
913
+ "target_model": self.device_content_type.pk,
914
+ # parents field is optional and empty for new instances
915
+ }
916
+ )
917
+
918
+ self.assertTrue(form.is_valid(), form.errors)
919
+
920
+ def test_no_validation_when_parents_empty(self):
921
+ """Test that circular dependency validation is skipped when no parents are selected"""
922
+ tm = IPFabricTransformMap.objects.create(
923
+ name="Test Transform Map",
924
+ group=self.transform_map_group,
925
+ source_endpoint=self.device_endpoint,
926
+ target_model=self.device_content_type,
927
+ )
928
+
929
+ # Edit without setting any parents
930
+ form = IPFabricTransformMapForm(
931
+ instance=tm,
932
+ data={
933
+ "name": "Updated Transform Map",
934
+ "group": self.transform_map_group.pk,
935
+ "source_endpoint": self.device_endpoint.pk,
936
+ "target_model": self.device_content_type.pk,
937
+ "parents": [], # Empty parents
938
+ },
939
+ )
940
+
941
+ self.assertTrue(form.is_valid(), form.errors)
942
+
626
943
 
627
944
  class IPFabricTransformMapCloneFormTestCase(TestCase):
628
945
  @classmethod
@@ -771,9 +1088,7 @@ class IPFabricSyncFormTestCase(TestCase):
771
1088
  source=cls.source,
772
1089
  snapshot_id="test-snapshot-id",
773
1090
  status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
774
- data={
775
- "sites": ["site1", "site2", "site3"]
776
- }, # Store as list instead of comma-separated string
1091
+ data={"sites": ["site1", "site2", "site3"]},
777
1092
  )
778
1093
 
779
1094
  cls.transform_map_group = IPFabricTransformMapGroup.objects.create(
@@ -931,6 +1246,31 @@ class IPFabricSyncFormTestCase(TestCase):
931
1246
  # But the provided initial value should be present
932
1247
  self.assertEqual(form.initial.get("name"), "Override Name")
933
1248
 
1249
+ def test_htmx_boolean_field_list_values_handled(self):
1250
+ """Test sanitizing HTMX BooleanField list values like ['', 'on']"""
1251
+ # Simulate HTMX request where BooleanField values become lists
1252
+ # This happens when `source` field value is changed and form is re-drawn via HTMX
1253
+ form = IPFabricSyncForm(
1254
+ initial={
1255
+ "auto_merge": ["", "on"], # HTMX sends BooleanField as list
1256
+ "update_custom_fields": ["", "on"], # Another BooleanField as list
1257
+ "name": "Test Sync", # Normal field (not affected)
1258
+ },
1259
+ data={
1260
+ "name": "Test Sync HTMX",
1261
+ "source": self.source.pk,
1262
+ "snapshot_data": self.snapshot.pk,
1263
+ },
1264
+ )
1265
+
1266
+ # The last value from ['', 'on'] should be 'on' which evaluates to True for BooleanFields
1267
+ self.assertEqual(form.initial["auto_merge"], "on")
1268
+ self.assertEqual(form.initial["update_custom_fields"], "on")
1269
+ self.assertEqual(form.initial["name"], "Test Sync") # Normal field unchanged
1270
+
1271
+ # Verify the form is still valid and processes correctly
1272
+ self.assertTrue(form.is_valid(), form.errors)
1273
+
934
1274
  def test_sites_initial_value_set_from_form_initial(self):
935
1275
  """Test that sites field initial is set from self.initial["sites"]"""
936
1276
  # Create an existing sync instance with sites in parameters
@@ -958,33 +1298,6 @@ class IPFabricSyncFormTestCase(TestCase):
958
1298
  # Also verify that self.initial contains the expected sites
959
1299
  self.assertEqual(form.initial["sites"], ["override_site1", "override_site2"])
960
1300
 
961
- def test_htmx_boolean_field_list_values_handled(self):
962
- """Test sanitizing HTMX BooleanField list values like ['', 'on']"""
963
- # Simulate HTMX request where BooleanField values become lists
964
- # This happens when `source` field value is changed and form is re-drawn via HTMX
965
- form = IPFabricSyncForm(
966
- initial={
967
- "auto_merge": ["", "on"], # HTMX sends BooleanField as list
968
- "update_custom_fields": ["", "on"], # Another BooleanField as list
969
- "ipf_site": ["", "on"], # ipf_ prefixed field as list
970
- "name": "Test Sync", # Normal field (not affected)
971
- },
972
- data={
973
- "name": "Test Sync HTMX",
974
- "source": self.source.pk,
975
- "snapshot_data": self.snapshot.pk,
976
- },
977
- )
978
-
979
- # The last value from ['', 'on'] should be 'on' which evaluates to True for BooleanFields
980
- self.assertEqual(form.initial["auto_merge"], "on")
981
- self.assertEqual(form.initial["update_custom_fields"], "on")
982
- self.assertEqual(form.initial["ipf_site"], "on")
983
- self.assertEqual(form.initial["name"], "Test Sync") # Normal field unchanged
984
-
985
- # Verify the form is still valid and processes correctly
986
- self.assertTrue(form.is_valid(), form.errors)
987
-
988
1301
  def test_htmx_boolean_field_single_values_unchanged(self):
989
1302
  """Test that normal single values are not affected by the HTMX list handling"""
990
1303
  # Test with normal single values (not lists)
@@ -992,7 +1305,6 @@ class IPFabricSyncFormTestCase(TestCase):
992
1305
  initial={
993
1306
  "auto_merge": True, # Normal boolean value
994
1307
  "update_custom_fields": False, # Normal boolean value
995
- "ipf_site": "on", # Normal string value
996
1308
  "name": "Test Sync", # Normal string value
997
1309
  },
998
1310
  data={
@@ -1005,7 +1317,6 @@ class IPFabricSyncFormTestCase(TestCase):
1005
1317
  # Verify that single values are not processed by value sanitization
1006
1318
  self.assertEqual(form.initial["auto_merge"], True)
1007
1319
  self.assertEqual(form.initial["update_custom_fields"], False)
1008
- self.assertEqual(form.initial["ipf_site"], "on")
1009
1320
  self.assertEqual(form.initial["name"], "Test Sync")
1010
1321
 
1011
1322
  # Verify the form is still valid
@@ -1161,9 +1472,9 @@ class IPFabricSyncFormTestCase(TestCase):
1161
1472
  form = IPFabricSyncForm(
1162
1473
  data={
1163
1474
  "name": "Test Sync Save",
1475
+ "sites": ["site1", "site2"],
1164
1476
  "source": self.source.pk,
1165
1477
  "snapshot_data": self.snapshot.pk,
1166
- "sites": ["site1", "site2"],
1167
1478
  "groups": [self.transform_map_group.pk],
1168
1479
  "auto_merge": True,
1169
1480
  "update_custom_fields": True,
@@ -1189,33 +1500,33 @@ class IPFabricSyncFormTestCase(TestCase):
1189
1500
  expected_parameters = {
1190
1501
  "sites": ["site1", "site2"],
1191
1502
  "groups": [self.transform_map_group.pk],
1192
- "site": False,
1193
- "manufacturer": False,
1194
- "devicetype": False,
1195
- "devicerole": False,
1196
- "platform": False,
1197
- "device": False,
1198
- "virtualchassis": False,
1199
- "interface": False,
1200
- "macaddress": False,
1201
- "inventoryitem": False,
1202
- "vlan": False,
1203
- "vrf": False,
1204
- "prefix": False,
1205
- "ipaddress": False,
1503
+ "dcim.site": False,
1504
+ "dcim.manufacturer": False,
1505
+ "dcim.devicetype": False,
1506
+ "dcim.devicerole": False,
1507
+ "dcim.platform": False,
1508
+ "dcim.device": False,
1509
+ "dcim.virtualchassis": False,
1510
+ "dcim.interface": False,
1511
+ "dcim.macaddress": False,
1512
+ "dcim.inventoryitem": False,
1513
+ "ipam.vlan": False,
1514
+ "ipam.vrf": False,
1515
+ "ipam.prefix": False,
1516
+ "ipam.ipaddress": False,
1206
1517
  }
1207
1518
  self.assertEqual(sync_instance.parameters, expected_parameters)
1208
1519
 
1209
- def test_save_method_with_ipf_parameters(self):
1210
- """Test save method properly handles ipf_ prefixed form fields"""
1520
+ def test_save_method_with_model_parameters(self):
1521
+ """Test save method properly handles model parameters form fields"""
1211
1522
  form = IPFabricSyncForm(
1212
1523
  data={
1213
1524
  "name": "Test Sync IPF Params",
1214
1525
  "source": self.source.pk,
1215
1526
  "snapshot_data": self.snapshot.pk,
1216
- "ipf_site": True,
1217
- "ipf_interface": True,
1218
- "ipf_prefix": True,
1527
+ "dcim.site": True,
1528
+ "dcim.interface": True,
1529
+ "ipam.prefix": True,
1219
1530
  }
1220
1531
  )
1221
1532
 
@@ -1223,25 +1534,25 @@ class IPFabricSyncFormTestCase(TestCase):
1223
1534
 
1224
1535
  sync_instance = form.save()
1225
1536
 
1226
- # Verify ipf_ parameters were stripped and stored correctly
1537
+ # Verify parameters were stripped and stored correctly
1227
1538
  # All models are `False` since checkboxes must always default to False
1228
1539
  expected_parameters = {
1229
1540
  "sites": [],
1230
1541
  "groups": [],
1231
- "site": True, # Explicitly set via ipf_site
1232
- "manufacturer": False,
1233
- "devicetype": False,
1234
- "devicerole": False,
1235
- "platform": False,
1236
- "device": False,
1237
- "virtualchassis": False,
1238
- "interface": True, # Explicitly set via ipf_interface
1239
- "macaddress": False,
1240
- "inventoryitem": False,
1241
- "ipaddress": False,
1242
- "vlan": False,
1243
- "vrf": False,
1244
- "prefix": True, # Explicitly set via ipf_prefix
1542
+ "dcim.site": True, # Explicitly set
1543
+ "dcim.manufacturer": False,
1544
+ "dcim.devicetype": False,
1545
+ "dcim.devicerole": False,
1546
+ "dcim.platform": False,
1547
+ "dcim.device": False,
1548
+ "dcim.virtualchassis": False,
1549
+ "dcim.interface": True, # Explicitly set
1550
+ "dcim.macaddress": False,
1551
+ "dcim.inventoryitem": False,
1552
+ "ipam.ipaddress": False,
1553
+ "ipam.vlan": False,
1554
+ "ipam.vrf": False,
1555
+ "ipam.prefix": True, # Explicitly set
1245
1556
  }
1246
1557
  self.assertEqual(sync_instance.parameters, expected_parameters)
1247
1558
 
@@ -1487,7 +1798,6 @@ class IPFabricSyncFormTestCase(TestCase):
1487
1798
 
1488
1799
  def test_fieldsets_property_returns_correct_field_types(self):
1489
1800
  """Test that fieldsets property returns FieldSet objects with correct structure"""
1490
- from utilities.forms.rendering import FieldSet
1491
1801
 
1492
1802
  form = IPFabricSyncForm(
1493
1803
  data={
@@ -1532,3 +1842,968 @@ class IPFabricSyncFormTestCase(TestCase):
1532
1842
  fieldset_names1 = [fs.name for fs in fieldsets1]
1533
1843
  fieldset_names2 = [fs.name for fs in fieldsets2]
1534
1844
  self.assertEqual(fieldset_names1, fieldset_names2)
1845
+
1846
+ def test_save_method_with_filters(self):
1847
+ """Test that filters many-to-many relationship is properly saved"""
1848
+ # Create some filter objects
1849
+ endpoint = IPFabricEndpoint.objects.create(
1850
+ name="Test Endpoint",
1851
+ endpoint="inventory/devices",
1852
+ )
1853
+ filter1 = IPFabricFilter.objects.create(
1854
+ name="Filter 1",
1855
+ filter_type="and",
1856
+ )
1857
+ filter1.endpoints.add(endpoint)
1858
+
1859
+ filter2 = IPFabricFilter.objects.create(
1860
+ name="Filter 2",
1861
+ filter_type="or",
1862
+ )
1863
+ filter2.endpoints.add(endpoint)
1864
+
1865
+ # Create a sync with filters
1866
+ form = IPFabricSyncForm(
1867
+ data={
1868
+ "name": "Test Sync With Filters",
1869
+ "source": self.source.pk,
1870
+ "snapshot_data": self.snapshot.pk,
1871
+ "filters": [filter1.pk, filter2.pk],
1872
+ }
1873
+ )
1874
+
1875
+ self.assertTrue(form.is_valid(), form.errors)
1876
+ sync_instance = form.save()
1877
+
1878
+ # Verify filters were saved correctly
1879
+ self.assertEqual(sync_instance.filters.count(), 2)
1880
+ self.assertIn(filter1, sync_instance.filters.all())
1881
+ self.assertIn(filter2, sync_instance.filters.all())
1882
+
1883
+ def test_update_method_with_filters(self):
1884
+ """Test that filters can be updated on existing sync"""
1885
+ # Create some filter objects
1886
+ endpoint = IPFabricEndpoint.objects.create(
1887
+ name="Test Endpoint Update",
1888
+ endpoint="inventory/devices",
1889
+ )
1890
+ filter1 = IPFabricFilter.objects.create(
1891
+ name="Filter Update 1",
1892
+ filter_type="and",
1893
+ )
1894
+ filter1.endpoints.add(endpoint)
1895
+
1896
+ filter2 = IPFabricFilter.objects.create(
1897
+ name="Filter Update 2",
1898
+ filter_type="or",
1899
+ )
1900
+ filter2.endpoints.add(endpoint)
1901
+
1902
+ filter3 = IPFabricFilter.objects.create(
1903
+ name="Filter Update 3",
1904
+ filter_type="and",
1905
+ )
1906
+ filter3.endpoints.add(endpoint)
1907
+
1908
+ # Create a sync with initial filters
1909
+ sync_instance = IPFabricSync.objects.create(
1910
+ name="Existing Sync With Filters",
1911
+ snapshot_data=self.snapshot,
1912
+ parameters={},
1913
+ )
1914
+ sync_instance.filters.set([filter1, filter2])
1915
+
1916
+ # Verify initial state
1917
+ self.assertEqual(sync_instance.filters.count(), 2)
1918
+
1919
+ # Update the sync with different filters
1920
+ form = IPFabricSyncForm(
1921
+ instance=sync_instance,
1922
+ data={
1923
+ "name": "Existing Sync With Filters",
1924
+ "source": self.source.pk,
1925
+ "snapshot_data": self.snapshot.pk,
1926
+ "filters": [filter2.pk, filter3.pk], # Changed filters
1927
+ },
1928
+ )
1929
+
1930
+ self.assertTrue(form.is_valid(), form.errors)
1931
+ updated_instance = form.save()
1932
+
1933
+ # Verify filters were updated correctly
1934
+ self.assertEqual(updated_instance.filters.count(), 2)
1935
+ self.assertNotIn(filter1, updated_instance.filters.all())
1936
+ self.assertIn(filter2, updated_instance.filters.all())
1937
+ self.assertIn(filter3, updated_instance.filters.all())
1938
+
1939
+
1940
+ class IPFabricFilterFormTestCase(TestCase):
1941
+ """Test cases for IPFabricFilterForm"""
1942
+
1943
+ @classmethod
1944
+ def setUpTestData(cls):
1945
+ # Create endpoints for testing
1946
+ cls.endpoint1 = IPFabricEndpoint.objects.get(endpoint="/inventory/devices")
1947
+ cls.endpoint2 = IPFabricEndpoint.objects.get(endpoint="/inventory/interfaces")
1948
+
1949
+ # Create filter expressions for testing
1950
+ cls.expression1 = IPFabricFilterExpression.objects.create(
1951
+ name="Test Expression 1",
1952
+ description="First test expression",
1953
+ expression=[{"siteName": ["eq", "Site1"]}],
1954
+ )
1955
+ cls.expression2 = IPFabricFilterExpression.objects.create(
1956
+ name="Test Expression 2",
1957
+ description="Second test expression",
1958
+ expression=[{"hostname": ["like", "router"]}],
1959
+ )
1960
+
1961
+ # Create a sync object for testing
1962
+ cls.source = IPFabricSource.objects.create(
1963
+ name="Test Source",
1964
+ type=IPFabricSourceTypeChoices.LOCAL,
1965
+ url="https://test.ipfabric.local",
1966
+ status=IPFabricSourceStatusChoices.NEW,
1967
+ )
1968
+ cls.snapshot = IPFabricSnapshot.objects.create(
1969
+ name="Test Snapshot",
1970
+ source=cls.source,
1971
+ snapshot_id="test-snapshot-id",
1972
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
1973
+ data={"sites": ["site1", "site2"]},
1974
+ )
1975
+ cls.sync = IPFabricSync.objects.create(
1976
+ name="Test Sync",
1977
+ snapshot_data=cls.snapshot,
1978
+ )
1979
+
1980
+ def test_fields_are_required(self):
1981
+ """Test that required fields are validated"""
1982
+ form = IPFabricFilterForm(data={})
1983
+ self.assertFalse(form.is_valid(), form.errors)
1984
+ self.assertIn("name", form.errors)
1985
+ self.assertIn("filter_type", form.errors)
1986
+
1987
+ def test_fields_are_optional(self):
1988
+ """Test that optional fields work correctly"""
1989
+ form = IPFabricFilterForm(
1990
+ data={
1991
+ "name": "Test Filter",
1992
+ "filter_type": IPFabricFilterTypeChoices.AND,
1993
+ }
1994
+ )
1995
+ self.assertTrue(form.is_valid(), form.errors)
1996
+
1997
+ def test_valid_filter_form_with_all_fields(self):
1998
+ """Test valid form submission with all fields"""
1999
+ form = IPFabricFilterForm(
2000
+ data={
2001
+ "name": "Test Filter Complete",
2002
+ "description": "A complete test filter",
2003
+ "filter_type": IPFabricFilterTypeChoices.OR,
2004
+ "endpoints": [self.endpoint1.pk, self.endpoint2.pk],
2005
+ "syncs": [self.sync.pk],
2006
+ "expressions": [self.expression1.pk, self.expression2.pk],
2007
+ }
2008
+ )
2009
+ self.assertTrue(form.is_valid(), form.errors)
2010
+ instance = form.save()
2011
+
2012
+ # Verify the instance was created correctly
2013
+ self.assertEqual(instance.name, "Test Filter Complete")
2014
+ self.assertEqual(instance.description, "A complete test filter")
2015
+ self.assertEqual(instance.filter_type, IPFabricFilterTypeChoices.OR)
2016
+
2017
+ # Verify many-to-many relationships
2018
+ self.assertEqual(
2019
+ list(instance.endpoints.all()), [self.endpoint1, self.endpoint2]
2020
+ )
2021
+ self.assertEqual(list(instance.syncs.all()), [self.sync])
2022
+ self.assertEqual(
2023
+ list(instance.expressions.all().order_by("pk")),
2024
+ [self.expression1, self.expression2],
2025
+ )
2026
+
2027
+ def test_valid_filter_form_with_and_type(self):
2028
+ """Test form with AND filter type"""
2029
+ form = IPFabricFilterForm(
2030
+ data={
2031
+ "name": "AND Filter",
2032
+ "filter_type": IPFabricFilterTypeChoices.AND,
2033
+ "expressions": [self.expression1.pk],
2034
+ }
2035
+ )
2036
+ self.assertTrue(form.is_valid(), form.errors)
2037
+ instance = form.save()
2038
+ self.assertEqual(instance.filter_type, IPFabricFilterTypeChoices.AND)
2039
+
2040
+ def test_valid_filter_form_with_or_type(self):
2041
+ """Test form with OR filter type"""
2042
+ form = IPFabricFilterForm(
2043
+ data={
2044
+ "name": "OR Filter",
2045
+ "filter_type": IPFabricFilterTypeChoices.OR,
2046
+ "expressions": [self.expression2.pk],
2047
+ }
2048
+ )
2049
+ self.assertTrue(form.is_valid(), form.errors)
2050
+ instance = form.save()
2051
+ self.assertEqual(instance.filter_type, IPFabricFilterTypeChoices.OR)
2052
+
2053
+ def test_filter_type_must_be_valid_choice(self):
2054
+ """Test that filter_type must be a valid choice"""
2055
+ form = IPFabricFilterForm(
2056
+ data={
2057
+ "name": "Invalid Filter",
2058
+ "filter_type": "invalid_type",
2059
+ }
2060
+ )
2061
+ self.assertFalse(form.is_valid(), form.errors)
2062
+ self.assertIn("filter_type", form.errors)
2063
+
2064
+ def test_form_initialization_with_existing_instance(self):
2065
+ """Test that when editing an existing filter, expressions are properly initialized"""
2066
+ # Create an existing filter with expressions
2067
+ existing_filter = IPFabricFilter.objects.create(
2068
+ name="Existing Filter",
2069
+ filter_type=IPFabricFilterTypeChoices.AND,
2070
+ )
2071
+ existing_filter.expressions.set([self.expression1, self.expression2])
2072
+
2073
+ # Initialize form with the existing instance
2074
+ form = IPFabricFilterForm(instance=existing_filter)
2075
+
2076
+ # Verify that expressions initial is set correctly
2077
+ expected_ids = list(
2078
+ existing_filter.expressions.all().values_list("id", flat=True)
2079
+ )
2080
+ actual_initial = (
2081
+ list(form.fields["expressions"].initial)
2082
+ if form.fields["expressions"].initial
2083
+ else []
2084
+ )
2085
+ self.assertEqual(actual_initial, expected_ids)
2086
+
2087
+ def test_form_initialization_with_new_instance(self):
2088
+ """Test form initialization for a new instance (no pk)"""
2089
+ # Create a new instance without saving
2090
+ new_filter = IPFabricFilter(
2091
+ name="New Filter",
2092
+ filter_type=IPFabricFilterTypeChoices.OR,
2093
+ )
2094
+
2095
+ # Initialize form with new instance (no pk)
2096
+ form = IPFabricFilterForm(instance=new_filter)
2097
+
2098
+ # Initial should not be set since instance.pk is None
2099
+ self.assertIsNone(form.fields["expressions"].initial)
2100
+
2101
+ def test_save_method_sets_expressions(self):
2102
+ """Test that save method properly sets the expressions relationship"""
2103
+ form = IPFabricFilterForm(
2104
+ data={
2105
+ "name": "Filter with Expressions",
2106
+ "filter_type": IPFabricFilterTypeChoices.AND,
2107
+ "expressions": [self.expression1.pk, self.expression2.pk],
2108
+ }
2109
+ )
2110
+ self.assertTrue(form.is_valid(), form.errors)
2111
+
2112
+ instance = form.save()
2113
+
2114
+ # Verify expressions were set correctly
2115
+ expression_ids = list(instance.expressions.all().values_list("id", flat=True))
2116
+ self.assertIn(self.expression1.pk, expression_ids)
2117
+ self.assertIn(self.expression2.pk, expression_ids)
2118
+ self.assertEqual(len(expression_ids), 2)
2119
+
2120
+ def test_save_method_updates_expressions(self):
2121
+ """Test that save method properly updates expressions on existing instance"""
2122
+ # Create an existing filter with one expression
2123
+ existing_filter = IPFabricFilter.objects.create(
2124
+ name="Existing Filter",
2125
+ filter_type=IPFabricFilterTypeChoices.AND,
2126
+ )
2127
+ existing_filter.expressions.set([self.expression1])
2128
+
2129
+ # Update the filter to use different expressions
2130
+ form = IPFabricFilterForm(
2131
+ data={
2132
+ "name": "Updated Filter",
2133
+ "filter_type": IPFabricFilterTypeChoices.OR,
2134
+ "expressions": [self.expression2.pk],
2135
+ },
2136
+ instance=existing_filter,
2137
+ )
2138
+ self.assertTrue(form.is_valid(), form.errors)
2139
+
2140
+ updated_instance = form.save()
2141
+
2142
+ # Verify expressions were updated
2143
+ expression_ids = list(
2144
+ updated_instance.expressions.all().values_list("id", flat=True)
2145
+ )
2146
+ self.assertEqual(expression_ids, [self.expression2.pk])
2147
+ self.assertEqual(updated_instance.filter_type, IPFabricFilterTypeChoices.OR)
2148
+
2149
+ def test_save_method_clears_expressions(self):
2150
+ """Test that save method can clear all expressions"""
2151
+ # Create an existing filter with expressions
2152
+ existing_filter = IPFabricFilter.objects.create(
2153
+ name="Filter with Expressions",
2154
+ filter_type=IPFabricFilterTypeChoices.AND,
2155
+ )
2156
+ existing_filter.expressions.set([self.expression1, self.expression2])
2157
+
2158
+ # Update the filter to have no expressions
2159
+ form = IPFabricFilterForm(
2160
+ data={
2161
+ "name": "Filter without Expressions",
2162
+ "filter_type": IPFabricFilterTypeChoices.AND,
2163
+ "expressions": [],
2164
+ },
2165
+ instance=existing_filter,
2166
+ )
2167
+ self.assertTrue(form.is_valid(), form.errors)
2168
+
2169
+ updated_instance = form.save()
2170
+
2171
+ # Verify expressions were cleared
2172
+ self.assertEqual(updated_instance.expressions.count(), 0)
2173
+
2174
+ def test_form_with_multiple_endpoints(self):
2175
+ """Test form with multiple endpoints"""
2176
+ form = IPFabricFilterForm(
2177
+ data={
2178
+ "name": "Multi-Endpoint Filter",
2179
+ "filter_type": IPFabricFilterTypeChoices.AND,
2180
+ "endpoints": [self.endpoint1.pk, self.endpoint2.pk],
2181
+ }
2182
+ )
2183
+ self.assertTrue(form.is_valid(), form.errors)
2184
+ instance = form.save()
2185
+
2186
+ self.assertEqual(instance.endpoints.count(), 2)
2187
+ self.assertIn(self.endpoint1, instance.endpoints.all())
2188
+ self.assertIn(self.endpoint2, instance.endpoints.all())
2189
+
2190
+ def test_form_with_multiple_syncs(self):
2191
+ """Test form with multiple syncs"""
2192
+ # Create another sync
2193
+ sync2 = IPFabricSync.objects.create(
2194
+ name="Test Sync 2",
2195
+ snapshot_data=self.snapshot,
2196
+ )
2197
+
2198
+ form = IPFabricFilterForm(
2199
+ data={
2200
+ "name": "Multi-Sync Filter",
2201
+ "filter_type": IPFabricFilterTypeChoices.OR,
2202
+ "syncs": [self.sync.pk, sync2.pk],
2203
+ }
2204
+ )
2205
+ self.assertTrue(form.is_valid(), form.errors)
2206
+ instance = form.save()
2207
+
2208
+ self.assertEqual(instance.syncs.count(), 2)
2209
+ self.assertIn(self.sync, instance.syncs.all())
2210
+ self.assertIn(sync2, instance.syncs.all())
2211
+
2212
+ def test_form_without_optional_fields(self):
2213
+ """Test that form works without any optional fields"""
2214
+ form = IPFabricFilterForm(
2215
+ data={
2216
+ "name": "Minimal Filter",
2217
+ "filter_type": IPFabricFilterTypeChoices.AND,
2218
+ }
2219
+ )
2220
+ self.assertTrue(form.is_valid(), form.errors)
2221
+ instance = form.save()
2222
+
2223
+ self.assertEqual(instance.name, "Minimal Filter")
2224
+ self.assertEqual(instance.endpoints.count(), 0)
2225
+ self.assertEqual(instance.syncs.count(), 0)
2226
+ self.assertEqual(instance.expressions.count(), 0)
2227
+
2228
+
2229
+ class IPFabricFilterExpressionFormTestCase(TestCase):
2230
+ """Test cases for IPFabricFilterExpressionForm"""
2231
+
2232
+ @classmethod
2233
+ def setUpTestData(cls):
2234
+ # Create filters for testing
2235
+ cls.filter1 = IPFabricFilter.objects.create(
2236
+ name="Test Filter 1",
2237
+ filter_type=IPFabricFilterTypeChoices.AND,
2238
+ )
2239
+ cls.filter2 = IPFabricFilter.objects.create(
2240
+ name="Test Filter 2",
2241
+ filter_type=IPFabricFilterTypeChoices.OR,
2242
+ )
2243
+
2244
+ def test_fields_are_required(self):
2245
+ """Test that required fields are validated"""
2246
+ form = IPFabricFilterExpressionForm(data={})
2247
+ self.assertFalse(form.is_valid(), form.errors)
2248
+ self.assertIn("name", form.errors)
2249
+ self.assertIn("expression", form.errors)
2250
+
2251
+ def test_fields_are_optional(self):
2252
+ """Test that optional fields work correctly"""
2253
+ form = IPFabricFilterExpressionForm(
2254
+ data={
2255
+ "name": "Test Expression",
2256
+ "expression": [{"siteName": ["eq", "Site1"]}],
2257
+ }
2258
+ )
2259
+ self.assertTrue(form.is_valid(), form.errors)
2260
+
2261
+ def test_valid_expression_form_with_all_fields(self):
2262
+ """Test valid form submission with all fields"""
2263
+ expression_data = [{"siteName": ["eq", "Site1"]}]
2264
+ form = IPFabricFilterExpressionForm(
2265
+ data={
2266
+ "name": "Complete Expression",
2267
+ "description": "A complete test expression",
2268
+ "expression": expression_data,
2269
+ "filters": [self.filter1.pk, self.filter2.pk],
2270
+ }
2271
+ )
2272
+ self.assertTrue(form.is_valid(), form.errors)
2273
+ instance = form.save()
2274
+
2275
+ # Verify the instance was created correctly
2276
+ self.assertEqual(instance.name, "Complete Expression")
2277
+ self.assertEqual(instance.description, "A complete test expression")
2278
+ self.assertEqual(instance.expression, expression_data)
2279
+
2280
+ # Verify many-to-many relationships
2281
+ self.assertEqual(instance.filters.count(), 2)
2282
+ self.assertIn(self.filter1, instance.filters.all())
2283
+ self.assertIn(self.filter2, instance.filters.all())
2284
+
2285
+ def test_valid_expression_with_simple_filter(self):
2286
+ """Test form with a simple site name filter"""
2287
+ form = IPFabricFilterExpressionForm(
2288
+ data={
2289
+ "name": "Site Filter",
2290
+ "expression": [{"siteName": ["eq", "Site1"]}],
2291
+ }
2292
+ )
2293
+ self.assertTrue(form.is_valid(), form.errors)
2294
+ instance = form.save()
2295
+ self.assertEqual(instance.expression, [{"siteName": ["eq", "Site1"]}])
2296
+
2297
+ def test_valid_expression_with_hostname_filter(self):
2298
+ """Test form with hostname like filter"""
2299
+ form = IPFabricFilterExpressionForm(
2300
+ data={
2301
+ "name": "Hostname Filter",
2302
+ "expression": [{"hostname": ["like", "router"]}],
2303
+ }
2304
+ )
2305
+ self.assertTrue(form.is_valid(), form.errors)
2306
+ instance = form.save()
2307
+ self.assertEqual(instance.expression, [{"hostname": ["like", "router"]}])
2308
+
2309
+ def test_valid_expression_with_complex_filter(self):
2310
+ """Test form with complex nested filter expression"""
2311
+ complex_expression = [
2312
+ {
2313
+ "or": [
2314
+ {"siteName": ["eq", "Site1"]},
2315
+ {"siteName": ["eq", "Site2"]},
2316
+ ]
2317
+ }
2318
+ ]
2319
+ form = IPFabricFilterExpressionForm(
2320
+ data={
2321
+ "name": "Complex Expression",
2322
+ "expression": complex_expression,
2323
+ }
2324
+ )
2325
+ self.assertTrue(form.is_valid(), form.errors)
2326
+ instance = form.save()
2327
+ self.assertEqual(instance.expression, complex_expression)
2328
+
2329
+ def test_valid_expression_with_multiple_conditions(self):
2330
+ """Test form with multiple filter conditions"""
2331
+ multi_condition_expression = [
2332
+ {"siteName": ["eq", "Site1"]},
2333
+ {"hostname": ["like", "switch"]},
2334
+ ]
2335
+ form = IPFabricFilterExpressionForm(
2336
+ data={
2337
+ "name": "Multi Condition",
2338
+ "expression": multi_condition_expression,
2339
+ }
2340
+ )
2341
+ self.assertTrue(form.is_valid(), form.errors)
2342
+ instance = form.save()
2343
+ self.assertEqual(instance.expression, multi_condition_expression)
2344
+
2345
+ def test_expression_must_be_valid_json(self):
2346
+ """Test that expression field accepts valid JSON"""
2347
+
2348
+ # Valid JSON that will be parsed
2349
+ valid_json_str = json.dumps([{"siteName": ["eq", "Site1"]}])
2350
+ form = IPFabricFilterExpressionForm(
2351
+ data={
2352
+ "name": "JSON Expression",
2353
+ "expression": json.loads(valid_json_str), # Pass as Python object
2354
+ }
2355
+ )
2356
+ self.assertTrue(form.is_valid(), form.errors)
2357
+
2358
+ def test_form_with_single_filter(self):
2359
+ """Test form with a single filter"""
2360
+ form = IPFabricFilterExpressionForm(
2361
+ data={
2362
+ "name": "Single Filter Expression",
2363
+ "expression": [{"siteName": ["eq", "Site1"]}],
2364
+ "filters": [self.filter1.pk],
2365
+ }
2366
+ )
2367
+ self.assertTrue(form.is_valid(), form.errors)
2368
+ instance = form.save()
2369
+
2370
+ self.assertEqual(instance.filters.count(), 1)
2371
+ self.assertIn(self.filter1, instance.filters.all())
2372
+
2373
+ def test_form_with_multiple_filters(self):
2374
+ """Test form with multiple filters"""
2375
+ form = IPFabricFilterExpressionForm(
2376
+ data={
2377
+ "name": "Multi Filter Expression",
2378
+ "expression": [{"hostname": ["like", "core"]}],
2379
+ "filters": [self.filter1.pk, self.filter2.pk],
2380
+ }
2381
+ )
2382
+ self.assertTrue(form.is_valid(), form.errors)
2383
+ instance = form.save()
2384
+
2385
+ self.assertEqual(instance.filters.count(), 2)
2386
+ self.assertIn(self.filter1, instance.filters.all())
2387
+ self.assertIn(self.filter2, instance.filters.all())
2388
+
2389
+ def test_form_without_filters(self):
2390
+ """Test that form works without any filters"""
2391
+ form = IPFabricFilterExpressionForm(
2392
+ data={
2393
+ "name": "No Filter Expression",
2394
+ "expression": [{"siteName": ["eq", "Site1"]}],
2395
+ }
2396
+ )
2397
+ self.assertTrue(form.is_valid(), form.errors)
2398
+ instance = form.save()
2399
+
2400
+ self.assertEqual(instance.filters.count(), 0)
2401
+
2402
+ def test_form_without_description(self):
2403
+ """Test that description is optional"""
2404
+ form = IPFabricFilterExpressionForm(
2405
+ data={
2406
+ "name": "No Description",
2407
+ "expression": [{"siteName": ["eq", "Site1"]}],
2408
+ }
2409
+ )
2410
+ self.assertTrue(form.is_valid(), form.errors)
2411
+ instance = form.save()
2412
+ self.assertEqual(instance.description, "")
2413
+
2414
+ def test_form_with_description(self):
2415
+ """Test form with description field"""
2416
+ description = "This is a detailed description of the filter expression"
2417
+ form = IPFabricFilterExpressionForm(
2418
+ data={
2419
+ "name": "With Description",
2420
+ "description": description,
2421
+ "expression": [{"siteName": ["eq", "Site1"]}],
2422
+ }
2423
+ )
2424
+ self.assertTrue(form.is_valid(), form.errors)
2425
+ instance = form.save()
2426
+ self.assertEqual(instance.description, description)
2427
+
2428
+ def test_form_update_existing_expression(self):
2429
+ """Test updating an existing expression"""
2430
+ # Create an existing expression
2431
+ existing_expression = IPFabricFilterExpression.objects.create(
2432
+ name="Existing Expression",
2433
+ expression=[{"siteName": ["eq", "OldSite"]}],
2434
+ )
2435
+ existing_expression.filters.set([self.filter1])
2436
+
2437
+ # Update it
2438
+ new_expression_data = [{"siteName": ["eq", "NewSite"]}]
2439
+ form = IPFabricFilterExpressionForm(
2440
+ data={
2441
+ "name": "Updated Expression",
2442
+ "description": "Updated description",
2443
+ "expression": new_expression_data,
2444
+ "filters": [self.filter2.pk],
2445
+ },
2446
+ instance=existing_expression,
2447
+ )
2448
+ self.assertTrue(form.is_valid(), form.errors)
2449
+ updated_instance = form.save()
2450
+
2451
+ # Verify updates
2452
+ self.assertEqual(updated_instance.name, "Updated Expression")
2453
+ self.assertEqual(updated_instance.description, "Updated description")
2454
+ self.assertEqual(updated_instance.expression, new_expression_data)
2455
+ self.assertEqual(list(updated_instance.filters.all()), [self.filter2])
2456
+
2457
+ def test_form_clear_filters(self):
2458
+ """Test clearing all filters from an existing expression"""
2459
+ # Create an existing expression with filters
2460
+ existing_expression = IPFabricFilterExpression.objects.create(
2461
+ name="Expression with Filters",
2462
+ expression=[{"siteName": ["eq", "Site1"]}],
2463
+ )
2464
+ existing_expression.filters.set([self.filter1, self.filter2])
2465
+
2466
+ # Clear filters
2467
+ form = IPFabricFilterExpressionForm(
2468
+ data={
2469
+ "name": "Expression without Filters",
2470
+ "expression": [{"siteName": ["eq", "Site1"]}],
2471
+ "filters": [],
2472
+ },
2473
+ instance=existing_expression,
2474
+ )
2475
+ self.assertTrue(form.is_valid(), form.errors)
2476
+ updated_instance = form.save()
2477
+
2478
+ # Verify filters were cleared
2479
+ self.assertEqual(updated_instance.filters.count(), 0)
2480
+
2481
+ def test_name_must_be_unique(self):
2482
+ """Test that name field must be unique"""
2483
+ # Create first expression
2484
+ IPFabricFilterExpression.objects.create(
2485
+ name="Unique Name",
2486
+ expression=[{"siteName": ["eq", "Site1"]}],
2487
+ )
2488
+
2489
+ # Try to create another with same name
2490
+ form = IPFabricFilterExpressionForm(
2491
+ data={
2492
+ "name": "Unique Name",
2493
+ "expression": [{"siteName": ["eq", "Site2"]}],
2494
+ }
2495
+ )
2496
+ self.assertFalse(form.is_valid())
2497
+ self.assertIn("name", form.errors)
2498
+
2499
+ def test_textarea_widget_for_expression(self):
2500
+ """Test that expression field uses Textarea widget with monospace class"""
2501
+ form = IPFabricFilterExpressionForm()
2502
+ expression_widget = form.fields["expression"].widget
2503
+
2504
+ # Check widget type
2505
+ self.assertIsInstance(expression_widget, forms.Textarea)
2506
+
2507
+ # Check that it has the monospace class
2508
+ self.assertIn("class", expression_widget.attrs)
2509
+ self.assertIn("font-monospace", expression_widget.attrs["class"])
2510
+
2511
+ def test_form_with_various_operator_types(self):
2512
+ """Test expressions with various operator types"""
2513
+ operators = [
2514
+ [{"siteName": ["eq", "Site1"]}], # equals
2515
+ [{"hostname": ["like", "%router%"]}], # like
2516
+ [{"hostname": ["reg", "^switch.*"]}], # regex
2517
+ [{"vlan": ["gt", 100]}], # greater than
2518
+ [{"vlan": ["lt", 200]}], # less than
2519
+ ]
2520
+
2521
+ for i, expression_data in enumerate(operators):
2522
+ form = IPFabricFilterExpressionForm(
2523
+ data={
2524
+ "name": f"Operator Test {i}",
2525
+ "expression": expression_data,
2526
+ }
2527
+ )
2528
+ self.assertTrue(
2529
+ form.is_valid(), f"Form invalid for {expression_data}: {form.errors}"
2530
+ )
2531
+ instance = form.save()
2532
+ self.assertEqual(instance.expression, expression_data)
2533
+
2534
+ def test_form_minimal_required_fields(self):
2535
+ """Test form with only minimal required fields"""
2536
+ form = IPFabricFilterExpressionForm(
2537
+ data={
2538
+ "name": "Minimal Expression",
2539
+ "expression": [{"siteName": ["eq", "Site1"]}],
2540
+ }
2541
+ )
2542
+ self.assertTrue(form.is_valid(), form.errors)
2543
+ instance = form.save()
2544
+
2545
+ self.assertEqual(instance.name, "Minimal Expression")
2546
+ self.assertEqual(instance.expression, [{"siteName": ["eq", "Site1"]}])
2547
+ self.assertEqual(instance.description, "")
2548
+ self.assertEqual(instance.filters.count(), 0)
2549
+
2550
+ def test_expression_must_be_list(self):
2551
+ """Test that expression must be a list (validation at model level)"""
2552
+ # Try to save with a string instead of list
2553
+ form = IPFabricFilterExpressionForm(
2554
+ data={
2555
+ "name": "Invalid String Expression",
2556
+ "expression": "not a list",
2557
+ }
2558
+ )
2559
+ self.assertFalse(form.is_valid())
2560
+ self.assertIn("expression", form.errors)
2561
+
2562
+ def test_expression_cannot_be_empty_list(self):
2563
+ """Test that expression cannot be an empty list (validation at model level)"""
2564
+ form = IPFabricFilterExpressionForm(
2565
+ data={
2566
+ "name": "Empty List Expression",
2567
+ "expression": [],
2568
+ }
2569
+ )
2570
+ self.assertFalse(form.is_valid())
2571
+ self.assertIn("expression", form.errors)
2572
+
2573
+ def test_expression_must_contain_dictionaries(self):
2574
+ """Test that expression items must be dictionaries (validation at model level)"""
2575
+ # Test with list containing strings
2576
+ form = IPFabricFilterExpressionForm(
2577
+ data={
2578
+ "name": "Invalid List Items",
2579
+ "expression": ["string1", "string2"],
2580
+ }
2581
+ )
2582
+ self.assertFalse(form.is_valid())
2583
+ self.assertIn("expression", form.errors)
2584
+
2585
+ def test_expression_with_mixed_types(self):
2586
+ """Test that expression rejects mixed types in list (validation at model level)"""
2587
+ form = IPFabricFilterExpressionForm(
2588
+ data={
2589
+ "name": "Mixed Types Expression",
2590
+ "expression": [{"siteName": ["eq", "Site1"]}, "not a dict", 123],
2591
+ }
2592
+ )
2593
+ self.assertFalse(form.is_valid())
2594
+ self.assertIn("expression", form.errors)
2595
+
2596
+ def test_expression_with_list_of_integers(self):
2597
+ """Test that expression rejects list of integers (validation at model level)"""
2598
+ form = IPFabricFilterExpressionForm(
2599
+ data={
2600
+ "name": "Integer List Expression",
2601
+ "expression": [1, 2, 3],
2602
+ }
2603
+ )
2604
+ self.assertFalse(form.is_valid())
2605
+ self.assertIn("expression", form.errors)
2606
+
2607
+ def test_expression_with_nested_structure(self):
2608
+ """Test that expression accepts complex nested dictionary structures"""
2609
+ complex_expr = [
2610
+ {
2611
+ "and": [
2612
+ {"siteName": ["eq", "Site1"]},
2613
+ {
2614
+ "or": [
2615
+ {"hostname": ["like", "router%"]},
2616
+ {"hostname": ["like", "switch%"]},
2617
+ ]
2618
+ },
2619
+ ]
2620
+ }
2621
+ ]
2622
+ form = IPFabricFilterExpressionForm(
2623
+ data={
2624
+ "name": "Nested Structure Expression",
2625
+ "expression": complex_expr,
2626
+ }
2627
+ )
2628
+ self.assertTrue(form.is_valid(), form.errors)
2629
+ instance = form.save()
2630
+ self.assertEqual(instance.expression, complex_expr)
2631
+
2632
+ def test_form_init_sets_test_source_initial_when_single_source(self):
2633
+ """Test that form __init__ sets test_source initial value when only one LOCAL source exists"""
2634
+
2635
+ # Create a LOCAL source
2636
+ source = IPFabricSource.objects.create(
2637
+ name="Test Local Source",
2638
+ type=IPFabricSourceTypeChoices.LOCAL,
2639
+ url="https://test.local",
2640
+ )
2641
+
2642
+ # Create snapshot and sync with LOCAL source
2643
+ snapshot = IPFabricSnapshot.objects.create(
2644
+ name="Test Snapshot",
2645
+ source=source,
2646
+ )
2647
+ sync = IPFabricSync.objects.create(
2648
+ name="Test Sync",
2649
+ snapshot_data=snapshot,
2650
+ )
2651
+
2652
+ # Create filter with endpoint
2653
+ filter_obj = IPFabricFilter.objects.create(
2654
+ name="Test Filter with Source",
2655
+ filter_type=IPFabricFilterTypeChoices.AND,
2656
+ )
2657
+ filter_obj.syncs.add(sync)
2658
+
2659
+ # Create expression and associate with filter
2660
+ expression = IPFabricFilterExpression.objects.create(
2661
+ name="Expression with Single Source",
2662
+ expression=[{"siteName": ["eq", "Site1"]}],
2663
+ )
2664
+ expression.filters.add(filter_obj)
2665
+
2666
+ # Create form with instance - should set test_source initial value
2667
+ form = IPFabricFilterExpressionForm(instance=expression)
2668
+
2669
+ # Verify test_source initial value is set
2670
+ self.assertEqual(form.fields["test_source"].initial, source)
2671
+
2672
+ def test_form_init_sets_test_endpoint_initial_when_single_endpoint(self):
2673
+ """Test that form __init__ sets test_endpoint initial value when only one endpoint exists"""
2674
+
2675
+ # Get or create an endpoint
2676
+ endpoint = IPFabricEndpoint.objects.first()
2677
+ if not endpoint:
2678
+ endpoint = IPFabricEndpoint.objects.create(
2679
+ name="Test Endpoint", endpoint="/tables/devices"
2680
+ )
2681
+
2682
+ # Create filter with endpoint
2683
+ filter_obj = IPFabricFilter.objects.create(
2684
+ name="Test Filter with Endpoint",
2685
+ filter_type=IPFabricFilterTypeChoices.AND,
2686
+ )
2687
+ filter_obj.endpoints.add(endpoint)
2688
+
2689
+ # Create expression and associate with filter
2690
+ expression = IPFabricFilterExpression.objects.create(
2691
+ name="Expression with Single Endpoint",
2692
+ expression=[{"siteName": ["eq", "Site1"]}],
2693
+ )
2694
+ expression.filters.add(filter_obj)
2695
+
2696
+ # Create form with instance - should set test_endpoint initial value
2697
+ form = IPFabricFilterExpressionForm(instance=expression)
2698
+
2699
+ # Verify test_endpoint initial value is set
2700
+ self.assertEqual(form.fields["test_endpoint"].initial, endpoint)
2701
+
2702
+ def test_form_init_does_not_set_initial_when_multiple_sources(self):
2703
+ """Test that form __init__ does not set test_source when multiple LOCAL sources exist"""
2704
+
2705
+ # Create two LOCAL sources
2706
+ source1 = IPFabricSource.objects.create(
2707
+ name="Test Local Source 1",
2708
+ type=IPFabricSourceTypeChoices.LOCAL,
2709
+ url="https://test1.local",
2710
+ )
2711
+ source2 = IPFabricSource.objects.create(
2712
+ name="Test Local Source 2",
2713
+ type=IPFabricSourceTypeChoices.LOCAL,
2714
+ url="https://test2.local",
2715
+ )
2716
+
2717
+ # Create snapshots and syncs
2718
+ snapshot1 = IPFabricSnapshot.objects.create(
2719
+ name="Test Snapshot 1", source=source1
2720
+ )
2721
+ snapshot2 = IPFabricSnapshot.objects.create(
2722
+ name="Test Snapshot 2", source=source2
2723
+ )
2724
+
2725
+ sync1 = IPFabricSync.objects.create(
2726
+ name="Test Sync 1",
2727
+ snapshot_data=snapshot1,
2728
+ )
2729
+ sync2 = IPFabricSync.objects.create(
2730
+ name="Test Sync 2",
2731
+ snapshot_data=snapshot2,
2732
+ )
2733
+
2734
+ # Create two filters with different syncs
2735
+ filter1 = IPFabricFilter.objects.create(
2736
+ name="Test Filter for Source 1",
2737
+ filter_type=IPFabricFilterTypeChoices.AND,
2738
+ )
2739
+ filter1.syncs.add(sync1)
2740
+
2741
+ filter2 = IPFabricFilter.objects.create(
2742
+ name="Test Filter for Source 2",
2743
+ filter_type=IPFabricFilterTypeChoices.AND,
2744
+ )
2745
+ filter2.syncs.add(sync2)
2746
+
2747
+ # Create expression and associate with both filters
2748
+ expression = IPFabricFilterExpression.objects.create(
2749
+ name="Expression with Multiple Sources",
2750
+ expression=[{"siteName": ["eq", "Site1"]}],
2751
+ )
2752
+ expression.filters.add(filter1, filter2)
2753
+
2754
+ # Create form with instance - should NOT set test_source initial value
2755
+ form = IPFabricFilterExpressionForm(instance=expression)
2756
+
2757
+ # Verify test_source initial value is NOT set
2758
+ self.assertIsNone(form.fields["test_source"].initial)
2759
+
2760
+ def test_form_init_does_not_set_initial_when_multiple_endpoints(self):
2761
+ """Test that form __init__ does not set test_endpoint when multiple endpoints exist"""
2762
+
2763
+ # Get or create two endpoints
2764
+ endpoint1 = IPFabricEndpoint.objects.filter(endpoint="/tables/devices").first()
2765
+ endpoint2 = IPFabricEndpoint.objects.filter(endpoint="/tables/sites").first()
2766
+
2767
+ if not endpoint1:
2768
+ endpoint1 = IPFabricEndpoint.objects.create(
2769
+ name="Devices Endpoint", endpoint="/tables/devices"
2770
+ )
2771
+ if not endpoint2:
2772
+ endpoint2 = IPFabricEndpoint.objects.create(
2773
+ name="Sites Endpoint", endpoint="/tables/sites"
2774
+ )
2775
+
2776
+ # Create two filters with different endpoints
2777
+ filter1 = IPFabricFilter.objects.create(
2778
+ name="Test Filter Devices",
2779
+ filter_type=IPFabricFilterTypeChoices.AND,
2780
+ )
2781
+ filter1.endpoints.add(endpoint1)
2782
+
2783
+ filter2 = IPFabricFilter.objects.create(
2784
+ name="Test Filter Sites",
2785
+ filter_type=IPFabricFilterTypeChoices.AND,
2786
+ )
2787
+ filter2.endpoints.add(endpoint2)
2788
+
2789
+ # Create expression and associate with both filters
2790
+ expression = IPFabricFilterExpression.objects.create(
2791
+ name="Expression with Multiple Endpoints",
2792
+ expression=[{"siteName": ["eq", "Site1"]}],
2793
+ )
2794
+ expression.filters.add(filter1, filter2)
2795
+
2796
+ # Create form with instance - should NOT set test_endpoint initial value
2797
+ form = IPFabricFilterExpressionForm(instance=expression)
2798
+
2799
+ # Verify test_endpoint initial value is NOT set
2800
+ self.assertIsNone(form.fields["test_endpoint"].initial)
2801
+
2802
+ def test_form_init_with_new_instance_does_not_set_initial(self):
2803
+ """Test that form __init__ does not set initial values for new instances"""
2804
+ # Create form without instance (new expression)
2805
+ form = IPFabricFilterExpressionForm()
2806
+
2807
+ # Verify no initial values are set
2808
+ self.assertIsNone(form.fields["test_source"].initial)
2809
+ self.assertIsNone(form.fields["test_endpoint"].initial)