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
ipfabric_netbox/forms.py CHANGED
@@ -1,5 +1,3 @@
1
- import copy
2
-
3
1
  from core.choices import JobIntervalChoices
4
2
  from django import forms
5
3
  from django.contrib.contenttypes.models import ContentType
@@ -29,13 +27,17 @@ from utilities.forms.widgets import DateTimePicker
29
27
  from utilities.forms.widgets import HTMXSelect
30
28
  from utilities.forms.widgets import NumberWithOptions
31
29
 
30
+ from .choices import IPFabricEndpointChoices
31
+ from .choices import IPFabricFilterTypeChoices
32
32
  from .choices import IPFabricSnapshotStatusModelChoices
33
33
  from .choices import IPFabricSourceStatusChoices
34
34
  from .choices import IPFabricSourceTypeChoices
35
35
  from .choices import IPFabricSyncStatusChoices
36
- from .choices import IPFabricTransformMapSourceModelChoices
37
36
  from .choices import required_transform_map_contenttypes
38
37
  from .choices import transform_field_source_columns
38
+ from .models import IPFabricEndpoint
39
+ from .models import IPFabricFilter
40
+ from .models import IPFabricFilterExpression
39
41
  from .models import IPFabricIngestion
40
42
  from .models import IPFabricRelationshipField
41
43
  from .models import IPFabricSnapshot
@@ -45,7 +47,8 @@ from .models import IPFabricSync
45
47
  from .models import IPFabricTransformField
46
48
  from .models import IPFabricTransformMap
47
49
  from .models import IPFabricTransformMapGroup
48
-
50
+ from .utilities.filters import get_filter_expression_test_candidates
51
+ from .utilities.transform_map import has_cycle_dfs
49
52
 
50
53
  exclude_fields = [
51
54
  "id",
@@ -56,47 +59,11 @@ exclude_fields = [
56
59
  "status",
57
60
  ]
58
61
 
59
- dcim_parameters = {
60
- "site": forms.BooleanField(required=False, label=_("Sites"), initial=True),
61
- "manufacturer": forms.BooleanField(
62
- required=False, label=_("Manufacturers"), initial=True
63
- ),
64
- "devicetype": forms.BooleanField(
65
- required=False, label=_("Device Types"), initial=True
66
- ),
67
- "devicerole": forms.BooleanField(
68
- required=False, label=_("Device Roles"), initial=True
69
- ),
70
- "platform": forms.BooleanField(required=False, label=_("Platforms"), initial=True),
71
- "device": forms.BooleanField(required=False, label=_("Devices"), initial=True),
72
- "virtualchassis": forms.BooleanField(
73
- required=False, label=_("Virtual Chassis"), initial=True
74
- ),
75
- "interface": forms.BooleanField(
76
- required=False, label=_("Interfaces"), initial=True
77
- ),
78
- "macaddress": forms.BooleanField(
79
- required=False, label=_("MAC Addresses"), initial=True
80
- ),
81
- "inventoryitem": forms.BooleanField(
82
- required=False, label=_("Part Numbers"), initial=True
83
- ),
84
- }
85
- ipam_parameters = {
86
- "vlan": forms.BooleanField(required=False, label=_("VLANs"), initial=True),
87
- "vrf": forms.BooleanField(required=False, label=_("VRFs"), initial=True),
88
- "prefix": forms.BooleanField(required=False, label=_("Prefixes"), initial=True),
89
- "ipaddress": forms.BooleanField(
90
- required=False, label=_("IP Addresses"), initial=True
91
- ),
92
- }
93
- sync_parameters = {"dcim": dcim_parameters, "ipam": ipam_parameters}
94
62
 
95
-
96
- def source_column_choices(model: str) -> list[tuple[str, str]]:
97
- columns = transform_field_source_columns.get(model, None)
63
+ def source_column_choices(endpoint: str) -> list[tuple[str, str]]:
64
+ columns = transform_field_source_columns.get(endpoint, None)
98
65
  if columns:
99
- choices = [(f, f) for f in transform_field_source_columns.get(model)]
66
+ choices = [(f, f) for f in transform_field_source_columns.get(endpoint)]
100
67
  else:
101
68
  # This should never happen, but better be safe than sorry
102
69
  choices = [] # pragma: no cover
@@ -113,10 +80,37 @@ def str_to_list(_str: str | list) -> list[str]:
113
80
  def list_to_choices(choices: list[str]) -> tuple[tuple[str, str], ...]:
114
81
  new_choices = ()
115
82
  for choice in choices:
116
- new_choices = new_choices + ((choice, choice),)
83
+ new_choices += ((choice, choice),)
117
84
  return new_choices
118
85
 
119
86
 
87
+ class IPFabricEndpointForm(NetBoxModelForm):
88
+ endpoint = CSVChoiceField(
89
+ label=_("Endpoint"),
90
+ choices=IPFabricEndpointChoices,
91
+ help_text=_("API endpoints available in IP Fabric to pull data from"),
92
+ )
93
+
94
+ class Meta:
95
+ model = IPFabricEndpoint
96
+ fields = ("name", "description", "endpoint")
97
+ widgets = {
98
+ "endpoint": HTMXSelect(),
99
+ }
100
+
101
+
102
+ class IPFabricEndpointBulkEditForm(NetBoxModelBulkEditForm):
103
+ model = IPFabricEndpoint
104
+ fields = ("endpoint",)
105
+ nullable_fields = ("endpoint",)
106
+
107
+
108
+ class IPFabricEndpointBulkImportForm(NetBoxModelImportForm):
109
+ class Meta:
110
+ model = IPFabricEndpoint
111
+ fields = ("name", "description", "endpoint")
112
+
113
+
120
114
  class IPFabricRelationshipFieldForm(NetBoxModelForm):
121
115
  coalesce = forms.BooleanField(required=False, initial=False)
122
116
  target_field = forms.CharField(
@@ -241,7 +235,6 @@ class IPFabricTransformFieldForm(NetBoxModelForm):
241
235
  fields = (
242
236
  self.instance.transform_map.target_model.model_class()._meta.fields
243
237
  )
244
- source_fields = self.instance.transform_map.source_model
245
238
  self.fields["target_field"].widget.choices = add_blank_choice(
246
239
  [
247
240
  (f.name, f.verbose_name)
@@ -251,7 +244,9 @@ class IPFabricTransformFieldForm(NetBoxModelForm):
251
244
  )
252
245
  self.fields["target_field"].widget.initial = self.instance.target_field
253
246
  self.fields["source_field"].widget.choices = add_blank_choice(
254
- source_column_choices(source_fields)
247
+ source_column_choices(
248
+ self.instance.transform_map.source_endpoint.endpoint
249
+ )
255
250
  )
256
251
  else:
257
252
  if kwargs.get("initial", {}).get("transform_map", None):
@@ -269,7 +264,7 @@ class IPFabricTransformFieldForm(NetBoxModelForm):
269
264
  choices
270
265
  )
271
266
  self.fields["source_field"].widget.choices = add_blank_choice(
272
- source_column_choices(transform_map.source_model)
267
+ source_column_choices(transform_map.source_endpoint.endpoint)
273
268
  )
274
269
 
275
270
 
@@ -294,13 +289,68 @@ class IPFabricTransformMapGroupBulkImportForm(NetBoxModelImportForm):
294
289
 
295
290
 
296
291
  class IPFabricTransformMapForm(NetBoxModelForm):
292
+ parents = DynamicModelMultipleChoiceField(
293
+ queryset=IPFabricTransformMap.objects.all(),
294
+ required=False,
295
+ label=_("Parents"),
296
+ help_text=_(
297
+ "Parent transform maps that must be processed before this one. "
298
+ "This transform map will only be processed after ALL selected parents complete. "
299
+ "Example: IP Address requires both Interface and VRF as parents."
300
+ ),
301
+ )
302
+
297
303
  class Meta:
298
304
  model = IPFabricTransformMap
299
- fields = ("name", "group", "source_model", "target_model")
305
+ fields = ("name", "group", "source_endpoint", "target_model", "parents")
300
306
  widgets = {
301
307
  "target_model": HTMXSelect(hx_url="/plugins/ipfabric/transform-map/add"),
302
308
  }
303
309
 
310
+ def __init__(self, *args, **kwargs):
311
+ super().__init__(*args, **kwargs)
312
+
313
+ if self.instance and self.instance.pk:
314
+ self.fields["parents"].widget.add_query_param("id__n", self.instance.pk)
315
+ self.fields["parents"].queryset = self.fields["parents"].queryset.exclude(
316
+ pk=self.instance.pk
317
+ )
318
+
319
+ def clean(self):
320
+ super().clean()
321
+
322
+ # Validate circular dependencies with the new parent selection
323
+ if self.instance and self.instance.pk:
324
+ new_parents = self.cleaned_data.get("parents", [])
325
+ if new_parents:
326
+ self._validate_no_circular_dependency_with_parents(new_parents)
327
+
328
+ def _validate_no_circular_dependency_with_parents(self, new_parents):
329
+ """
330
+ Check if adding these parents would create a circular dependency.
331
+ Uses DFS to detect cycles considering the new parent set.
332
+ """
333
+
334
+ def get_parents(node_id: int, parent_override: list | None):
335
+ """Get parents for a node, with optional override for the instance being modified."""
336
+ if node_id == self.instance.pk and parent_override is not None:
337
+ # Use the new parents for the current node
338
+ return parent_override
339
+ else:
340
+ # Use existing parents for other nodes
341
+ node = IPFabricTransformMap.objects.get(pk=node_id)
342
+ return node.parents.all()
343
+
344
+ if has_cycle_dfs(self.instance.pk, get_parents, parent_override=new_parents):
345
+ raise forms.ValidationError(
346
+ {
347
+ "parents": _(
348
+ "The selected parents create a circular dependency. "
349
+ "A transform map cannot be an ancestor of itself."
350
+ )
351
+ }
352
+ )
353
+
304
354
 
305
355
  class IPFabricTransformMapBulkEditForm(NetBoxModelBulkEditForm):
306
356
  group = forms.ModelChoiceField(
@@ -308,16 +358,23 @@ class IPFabricTransformMapBulkEditForm(NetBoxModelBulkEditForm):
308
358
  required=False,
309
359
  label=_("Target Group"),
310
360
  )
361
+ parents = forms.ModelMultipleChoiceField(
362
+ queryset=IPFabricTransformMap.objects.all(),
363
+ required=False,
364
+ label=_("Parents"),
365
+ )
366
+
311
367
  model = IPFabricTransformMap
312
- fields = ("group",)
313
- nullable_fields = ("group",)
368
+ fields = ("group", "parents")
369
+ nullable_fields = ("group", "parents")
314
370
 
315
371
 
316
372
  class IPFabricTransformMapBulkImportForm(NetBoxModelImportForm):
317
- source_model = CSVChoiceField(
318
- label=_("Source model"),
319
- choices=IPFabricTransformMapSourceModelChoices,
320
- help_text=_("Models available in IP Fabric to source data from"),
373
+ source_endpoint = CSVModelChoiceField(
374
+ label=_("Endpoints"),
375
+ queryset=IPFabricEndpoint.objects.all(),
376
+ required=True,
377
+ to_field_name="name",
321
378
  )
322
379
  target_model = CSVContentTypeField(
323
380
  queryset=ContentType.objects.filter(IPFabricSupportedSyncModels),
@@ -337,7 +394,7 @@ class IPFabricTransformMapBulkImportForm(NetBoxModelImportForm):
337
394
 
338
395
  class Meta:
339
396
  model = IPFabricTransformMap
340
- fields = ("name", "source_model", "target_model", "group")
397
+ fields = ("name", "source_endpoint", "target_model", "group", "parents")
341
398
 
342
399
 
343
400
  class IPFabricTransformMapCloneForm(forms.Form):
@@ -398,7 +455,7 @@ class IPFabricIngestionFilterForm(SavedFiltersMixin, FilterForm):
398
455
  )
399
456
  model = IPFabricIngestion
400
457
  sync_id = DynamicModelMultipleChoiceField(
401
- queryset=IPFabricSync.objects.all(), required=False, label=_("Sync")
458
+ queryset=IPFabricSync.objects.all(), required=False, label=_("Syncs")
402
459
  )
403
460
 
404
461
 
@@ -573,6 +630,12 @@ class IPFabricSyncForm(NetBoxModelForm):
573
630
  ),
574
631
  )
575
632
 
633
+ filters = DynamicModelMultipleChoiceField(
634
+ queryset=IPFabricFilter.objects.all(),
635
+ label=_("Filters"),
636
+ required=False,
637
+ )
638
+
576
639
  update_custom_fields = forms.BooleanField(
577
640
  required=False,
578
641
  label=_("Custom Fields Updating"),
@@ -607,6 +670,7 @@ class IPFabricSyncForm(NetBoxModelForm):
607
670
  "auto_merge",
608
671
  "update_custom_fields",
609
672
  "sites",
673
+ "filters",
610
674
  "tags",
611
675
  "scheduled",
612
676
  "interval",
@@ -620,11 +684,8 @@ class IPFabricSyncForm(NetBoxModelForm):
620
684
  for name, value in initial.items():
621
685
  if (
622
686
  (
623
- name.startswith("ipf_")
624
- or (
625
- name in self.base_fields
626
- and isinstance(self.base_fields[name], forms.BooleanField)
627
- )
687
+ name in self.base_fields
688
+ and isinstance(self.base_fields[name], forms.BooleanField)
628
689
  )
629
690
  and isinstance(value, list)
630
691
  and len(value) > 1
@@ -688,34 +749,66 @@ class IPFabricSyncForm(NetBoxModelForm):
688
749
  else:
689
750
  self.fields["sites"].initial = self.initial["sites"]
690
751
 
752
+ if "filters" not in self.initial:
753
+ self.initial["filters"] = self.instance.filters.all()
754
+ else:
755
+ # For new instances, populate default filters (filters with names starting with "Default ")
756
+ if "filters" not in self.initial or not self.initial.get("filters"):
757
+ self.initial["filters"] = IPFabricFilter.objects.filter(
758
+ name__istartswith="Default "
759
+ )
760
+
691
761
  now = local_now().strftime("%Y-%m-%d %H:%M:%S")
692
762
  self.fields["scheduled"].help_text += f" (current time: <strong>{now}</strong>)"
693
763
 
694
764
  # Add backend-specific form fields
695
765
  self.backend_fields = {}
766
+ for transform_map in IPFabricSync.get_transform_maps():
767
+ field = transform_map.target_model
768
+ if field.app_label not in self.backend_fields:
769
+ self.backend_fields[field.app_label] = []
770
+ self.backend_fields[field.app_label].append(
771
+ f"{field.app_label}.{field.model}"
772
+ )
696
773
 
697
- # Prepare buttons for each target Model
698
- for k, v in sync_parameters.items():
699
- self.backend_fields[k] = []
700
- for name, form_field in v.items():
701
- field_name = f"ipf_{name}"
702
- self.backend_fields[k].append(field_name)
703
- self.fields[field_name] = copy.deepcopy(form_field)
774
+ # Prepare buttons for each target Model, order according to model hierarchy
775
+ hierarchy = [
776
+ f"{tm.target_model.app_label}.{tm.target_model.model}"
777
+ for tm in IPFabricSync.get_model_hierarchy(
778
+ group_ids=self.initial.get("groups", [])
779
+ )
780
+ ]
781
+ for k, v in self.backend_fields.items():
782
+ self.backend_fields[k] = [
783
+ f for f in hierarchy if f in self.backend_fields[k]
784
+ ]
785
+ # Now that it's sorted, we can add those fields to have them in correct order
786
+ for field in self.backend_fields[k]:
787
+ self.fields[field] = forms.BooleanField(
788
+ required=False,
789
+ label=field.split(".", maxsplit=1).pop(),
790
+ initial=True,
791
+ )
704
792
  if self.instance and self.instance.parameters:
705
- value = self.instance.parameters.get(name)
706
- self.fields[field_name].initial = value
793
+ value = self.instance.parameters.get(field)
794
+ self.fields[field].initial = value
707
795
 
708
796
  # Set fieldsets dynamically based and backend_fields
709
797
  fieldsets = [
710
798
  FieldSet("name", "source", "groups", name=_("IP Fabric Source")),
711
799
  ]
712
- # Only show snapshot and sites if source is selected
800
+ # Only show snapshot, sites and filters if source is selected
713
801
  if source:
714
802
  if isinstance(source, str) or isinstance(source, int):
715
803
  source = IPFabricSource.objects.get(pk=source)
716
804
  if source.type == "local":
717
805
  fieldsets.append(
718
- FieldSet("snapshot_data", "sites", name=_("Snapshot Information")),
806
+ FieldSet(
807
+ "snapshot_data",
808
+ "sites",
809
+ "filters",
810
+ name=_("Snapshot Information"),
811
+ ),
719
812
  )
720
813
  else:
721
814
  fieldsets.append(
@@ -749,6 +842,10 @@ class IPFabricSyncForm(NetBoxModelForm):
749
842
  {"snapshot_data": _("Snapshot does not belong to the selected source.")}
750
843
  )
751
844
 
845
+ scheduled_time = self.cleaned_data.get("scheduled")
846
+ if scheduled_time and scheduled_time < local_now():
847
+ raise forms.ValidationError(_("Scheduled time must be in the future."))
848
+
752
849
  sites = self.data.get("sites")
753
850
  self.fields["sites"].choices = list_to_choices(str_to_list(sites))
754
851
  if sites and "snapshot_data" in self.cleaned_data:
@@ -768,10 +865,6 @@ class IPFabricSyncForm(NetBoxModelForm):
768
865
  {"sites": _(f"Sites {invalid_sites} not part of the snapshot.")}
769
866
  )
770
867
 
771
- scheduled_time = self.cleaned_data.get("scheduled")
772
- if scheduled_time and scheduled_time < local_now():
773
- raise forms.ValidationError(_("Scheduled time must be in the future."))
774
-
775
868
  # When interval is used without schedule at, schedule for the current time
776
869
  if self.cleaned_data.get("interval") and not scheduled_time:
777
870
  self.cleaned_data["scheduled"] = local_now()
@@ -796,18 +889,26 @@ class IPFabricSyncForm(NetBoxModelForm):
796
889
 
797
890
  def save(self, *args, **kwargs):
798
891
  parameters = {}
892
+ backend_fields_values = {
893
+ item for lst in self.backend_fields.values() for item in lst
894
+ }
799
895
  for name in self.fields:
800
- if name.startswith("ipf_"):
801
- parameters[name[4:]] = self.cleaned_data[name]
896
+ if name in backend_fields_values:
897
+ parameters[name] = self.cleaned_data[name]
802
898
  if name == "sites":
803
899
  parameters["sites"] = self.cleaned_data["sites"]
804
900
  if name == "groups":
805
901
  parameters["groups"] = [
806
902
  group.pk for group in self.cleaned_data["groups"]
807
903
  ]
808
- self.instance.parameters = parameters
904
+ self.instance.parameters = dict(sorted(parameters.items()))
809
905
  self.instance.status = IPFabricSyncStatusChoices.NEW
810
- return super().save(*args, **kwargs)
906
+
907
+ instance = super().save(*args, **kwargs)
908
+ # M2M relationships need to be set after the instance is saved
909
+ # But only if they are set from the side where they are not defined on model
910
+ instance.filters.set(self.cleaned_data["filters"])
911
+ return instance
811
912
 
812
913
 
813
914
  class IPFabricSyncBulkEditForm(NetBoxModelBulkEditForm):
@@ -869,6 +970,155 @@ class IPFabricSyncBulkEditForm(NetBoxModelBulkEditForm):
869
970
  )
870
971
 
871
972
 
973
+ class IPFabricFilterForm(NetBoxModelForm):
974
+ endpoints = DynamicModelMultipleChoiceField(
975
+ label=_("Endpoints"),
976
+ queryset=IPFabricEndpoint.objects.all(),
977
+ required=False,
978
+ widget=APISelectMultiple(
979
+ api_url="/api/plugins/ipfabric/endpoint/",
980
+ ),
981
+ )
982
+ filter_type = CSVChoiceField(
983
+ label=_("Filter Type"),
984
+ choices=IPFabricFilterTypeChoices,
985
+ help_text=_(
986
+ "Top-level merging of filter, where this will be used along other filters."
987
+ ),
988
+ )
989
+ expressions = DynamicModelMultipleChoiceField(
990
+ queryset=IPFabricFilterExpression.objects.all(),
991
+ label=_("Filter Expressions"),
992
+ required=False,
993
+ widget=APISelectMultiple(
994
+ api_url="/api/plugins/ipfabric/filter-expression/",
995
+ ),
996
+ )
997
+
998
+ class Meta:
999
+ model = IPFabricFilter
1000
+ fields = (
1001
+ "name",
1002
+ "description",
1003
+ "endpoints",
1004
+ "filter_type",
1005
+ "syncs",
1006
+ "expressions",
1007
+ )
1008
+ widgets = {
1009
+ "endpoints": forms.SelectMultiple(),
1010
+ "filter_type": forms.Select(),
1011
+ "syncs": forms.SelectMultiple(),
1012
+ }
1013
+
1014
+ def __init__(self, data=None, instance=None, *args, **kwargs):
1015
+ super().__init__(data=data, instance=instance, *args, **kwargs)
1016
+
1017
+ if self.instance and self.instance.pk is not None:
1018
+ self.fields[
1019
+ "expressions"
1020
+ ].initial = self.instance.expressions.all().values_list("id", flat=True)
1021
+
1022
+ def save(self, *args, **kwargs):
1023
+ instance = super().save(*args, **kwargs)
1024
+ instance.expressions.set(self.cleaned_data["expressions"])
1025
+ return instance
1026
+
1027
+
1028
+ class IPFabricFilterBulkEditForm(NetBoxModelBulkEditForm):
1029
+ endpoints = DynamicModelMultipleChoiceField(
1030
+ queryset=IPFabricEndpoint.objects.all(),
1031
+ required=False,
1032
+ label=_("Endpoints"),
1033
+ )
1034
+ filter_type = forms.ChoiceField(
1035
+ choices=add_blank_choice(IPFabricFilterTypeChoices),
1036
+ required=False,
1037
+ label=_("Filter Type"),
1038
+ )
1039
+ syncs = DynamicModelMultipleChoiceField(
1040
+ queryset=IPFabricSync.objects.all(),
1041
+ required=False,
1042
+ label=_("Syncs"),
1043
+ )
1044
+
1045
+ model = IPFabricFilter
1046
+ fields = ("endpoints", "filter_type", "syncs")
1047
+ nullable_fields = ("syncs", "endpoints")
1048
+
1049
+
1050
+ class IPFabricFilterBulkImportForm(NetBoxModelImportForm):
1051
+ class Meta:
1052
+ model = IPFabricFilter
1053
+ fields = ("name", "description", "endpoints", "filter_type", "syncs")
1054
+
1055
+
1056
+ class IPFabricFilterExpressionForm(NetBoxModelForm):
1057
+ filters = forms.ModelMultipleChoiceField(
1058
+ queryset=IPFabricFilter.objects.all(),
1059
+ required=False,
1060
+ widget=forms.SelectMultiple(),
1061
+ )
1062
+
1063
+ # Test expression fields - for testing the filter against IP Fabric API
1064
+ test_source = DynamicModelChoiceField(
1065
+ queryset=IPFabricSource.objects.filter(type=IPFabricSourceTypeChoices.LOCAL),
1066
+ required=False,
1067
+ label=_("Test Source"),
1068
+ help_text=_(
1069
+ "IP Fabric source to test against. Auto-detected from associated filters if available."
1070
+ ),
1071
+ )
1072
+ test_endpoint = DynamicModelChoiceField(
1073
+ queryset=IPFabricEndpoint.objects.all(),
1074
+ required=False,
1075
+ label=_("Test Endpoint"),
1076
+ help_text=_(
1077
+ "Endpoint to query. Auto-detected from associated filters if available."
1078
+ ),
1079
+ )
1080
+
1081
+ fieldsets = (
1082
+ FieldSet("name", "description", name=_("Filter Expression")),
1083
+ FieldSet("filters", "expression", name=_("Configuration")),
1084
+ FieldSet("test_source", "test_endpoint", name=_("Test Expression")),
1085
+ )
1086
+
1087
+ class Meta:
1088
+ model = IPFabricFilterExpression
1089
+ fields = ("name", "description", "filters", "expression")
1090
+ widgets = {
1091
+ "filters": forms.SelectMultiple(),
1092
+ "expression": forms.Textarea(attrs={"class": "font-monospace"}),
1093
+ }
1094
+
1095
+ def __init__(self, *args, **kwargs):
1096
+ super().__init__(*args, **kwargs)
1097
+
1098
+ # Auto-detect test parameters from associated filters if editing existing expression
1099
+ if self.instance and self.instance.pk:
1100
+ # Get unique sources and endpoints from associated filters
1101
+ sources, endpoints = get_filter_expression_test_candidates(self.instance)
1102
+
1103
+ # Set smart defaults if only one option exists
1104
+ if sources and len(sources) == 1:
1105
+ self.fields["test_source"].initial = list(sources)[0]
1106
+ if endpoints and len(endpoints) == 1:
1107
+ self.fields["test_endpoint"].initial = list(endpoints)[0]
1108
+
1109
+
1110
+ class IPFabricFilterExpressionBulkEditForm(NetBoxModelBulkEditForm):
1111
+ model = IPFabricFilterExpression
1112
+ fields = ("description", "filters")
1113
+ nullable_fields = ("description", "filters")
1114
+
1115
+
1116
+ class IPFabricFilterExpressionBulkImportForm(NetBoxModelImportForm):
1117
+ class Meta:
1118
+ model = IPFabricFilterExpression
1119
+ fields = ("name", "description", "expression")
1120
+
1121
+
872
1122
  tableChoices = [
873
1123
  ("eol_details", "Inventory - EOL_DETAILS"),
874
1124
  ("fans", "Inventory - FANS"),
@@ -1,4 +1,7 @@
1
1
  from .schema import IPFabricDataQuery
2
+ from .schema import IPFabricEndpointQuery
3
+ from .schema import IPFabricFilterExpressionQuery
4
+ from .schema import IPFabricFilterQuery
2
5
  from .schema import IPFabricIngestionIssueQuery
3
6
  from .schema import IPFabricIngestionQuery
4
7
  from .schema import IPFabricRelationshipFieldQuery
@@ -20,4 +23,7 @@ schema = [
20
23
  IPFabricIngestionQuery,
21
24
  IPFabricIngestionIssueQuery,
22
25
  IPFabricDataQuery,
26
+ IPFabricEndpointQuery,
27
+ IPFabricFilterExpressionQuery,
28
+ IPFabricFilterQuery,
23
29
  ]
@@ -2,22 +2,22 @@ import strawberry
2
2
  from core.choices import JobStatusChoices
3
3
  from netbox_branching.choices import BranchStatusChoices
4
4
 
5
+ from ipfabric_netbox.choices import IPFabricFilterTypeChoices
5
6
  from ipfabric_netbox.choices import IPFabricRawDataTypeChoices
6
7
  from ipfabric_netbox.choices import IPFabricSnapshotStatusModelChoices
7
8
  from ipfabric_netbox.choices import IPFabricSourceStatusChoices
8
9
  from ipfabric_netbox.choices import IPFabricSourceTypeChoices
9
10
  from ipfabric_netbox.choices import IPFabricSyncStatusChoices
10
- from ipfabric_netbox.choices import IPFabricTransformMapSourceModelChoices
11
11
 
12
12
  __all__ = (
13
13
  "IPFabricSourceStatusEnum",
14
14
  "IPFabricSyncStatusEnum",
15
- "IPFabricTransformMapSourceModelEnum",
16
15
  "IPFabricSourceTypeEnum",
17
16
  "IPFabricSnapshotStatusModelEnum",
18
17
  "IPFabricRawDataTypeEnum",
19
18
  "BranchStatusEnum",
20
19
  "JobStatusEnum",
20
+ "IPFabricFilterTypeEnum",
21
21
  )
22
22
 
23
23
  IPFabricSourceStatusEnum = strawberry.enum(
@@ -26,9 +26,6 @@ IPFabricSourceStatusEnum = strawberry.enum(
26
26
  IPFabricSyncStatusEnum = strawberry.enum(
27
27
  IPFabricSyncStatusChoices.as_enum(prefix="type")
28
28
  )
29
- IPFabricTransformMapSourceModelEnum = strawberry.enum(
30
- IPFabricTransformMapSourceModelChoices.as_enum(prefix="type")
31
- )
32
29
  IPFabricSourceTypeEnum = strawberry.enum(
33
30
  IPFabricSourceTypeChoices.as_enum(prefix="type")
34
31
  )
@@ -40,3 +37,6 @@ IPFabricRawDataTypeEnum = strawberry.enum(
40
37
  )
41
38
  BranchStatusEnum = strawberry.enum(BranchStatusChoices.as_enum(prefix="type"))
42
39
  JobStatusEnum = strawberry.enum(JobStatusChoices.as_enum(prefix="type"))
40
+ IPFabricFilterTypeEnum = strawberry.enum(
41
+ IPFabricFilterTypeChoices.as_enum(prefix="type")
42
+ )