ipfabric_netbox 4.3.2b9__py3-none-any.whl → 4.3.2b10__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.
- ipfabric_netbox/__init__.py +2 -2
- ipfabric_netbox/api/serializers.py +112 -7
- ipfabric_netbox/api/urls.py +6 -0
- ipfabric_netbox/api/views.py +23 -0
- ipfabric_netbox/choices.py +72 -40
- ipfabric_netbox/data/endpoint.json +47 -0
- ipfabric_netbox/data/filters.json +51 -0
- ipfabric_netbox/data/transform_map.json +188 -174
- ipfabric_netbox/exceptions.py +7 -5
- ipfabric_netbox/filtersets.py +310 -41
- ipfabric_netbox/forms.py +324 -79
- ipfabric_netbox/graphql/__init__.py +6 -0
- ipfabric_netbox/graphql/enums.py +5 -5
- ipfabric_netbox/graphql/filters.py +56 -4
- ipfabric_netbox/graphql/schema.py +28 -0
- ipfabric_netbox/graphql/types.py +61 -1
- ipfabric_netbox/jobs.py +5 -1
- ipfabric_netbox/migrations/0022_prepare_for_filters.py +182 -0
- ipfabric_netbox/migrations/0023_populate_filters_data.py +279 -0
- ipfabric_netbox/migrations/0024_finish_filters.py +29 -0
- ipfabric_netbox/models.py +384 -12
- ipfabric_netbox/navigation.py +98 -24
- ipfabric_netbox/tables.py +194 -9
- ipfabric_netbox/templates/ipfabric_netbox/htmx_list.html +5 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions.html +59 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions_content.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/endpoint_filters_with_selector.html +54 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricendpoint.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilter.html +51 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression_edit.html +150 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html +1 -1
- ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap.html +16 -2
- ipfabric_netbox/templatetags/ipfabric_netbox_helpers.py +65 -0
- ipfabric_netbox/tests/api/test_api.py +333 -13
- ipfabric_netbox/tests/test_filtersets.py +2592 -0
- ipfabric_netbox/tests/test_forms.py +1256 -74
- ipfabric_netbox/tests/test_models.py +242 -34
- ipfabric_netbox/tests/test_views.py +2030 -25
- ipfabric_netbox/urls.py +35 -0
- ipfabric_netbox/utilities/endpoint.py +30 -0
- ipfabric_netbox/utilities/filters.py +88 -0
- ipfabric_netbox/utilities/ipfutils.py +254 -316
- ipfabric_netbox/utilities/logging.py +7 -7
- ipfabric_netbox/utilities/transform_map.py +126 -0
- ipfabric_netbox/views.py +719 -5
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b10.dist-info}/METADATA +3 -2
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b10.dist-info}/RECORD +49 -33
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b10.dist-info}/WHEEL +1 -1
ipfabric_netbox/models.py
CHANGED
|
@@ -24,7 +24,10 @@ from django.core.validators import MinValueValidator
|
|
|
24
24
|
from django.db import models
|
|
25
25
|
from django.db import transaction
|
|
26
26
|
from django.db.models import Q
|
|
27
|
+
from django.db.models import QuerySet
|
|
27
28
|
from django.db.models import signals
|
|
29
|
+
from django.db.models.signals import m2m_changed
|
|
30
|
+
from django.dispatch import receiver
|
|
28
31
|
from django.urls import reverse
|
|
29
32
|
from django.utils import timezone
|
|
30
33
|
from django.utils.module_loading import import_string
|
|
@@ -42,19 +45,20 @@ from netbox_branching.utilities import supports_branching
|
|
|
42
45
|
from utilities.querysets import RestrictedQuerySet
|
|
43
46
|
from utilities.request import NetBoxFakeRequest
|
|
44
47
|
|
|
48
|
+
from .choices import IPFabricEndpointChoices
|
|
49
|
+
from .choices import IPFabricFilterTypeChoices
|
|
45
50
|
from .choices import IPFabricRawDataTypeChoices
|
|
46
51
|
from .choices import IPFabricSnapshotStatusModelChoices
|
|
47
52
|
from .choices import IPFabricSourceStatusChoices
|
|
48
53
|
from .choices import IPFabricSourceTypeChoices
|
|
49
54
|
from .choices import IPFabricSyncStatusChoices
|
|
50
|
-
from .choices import IPFabricTransformMapSourceModelChoices
|
|
51
55
|
from .choices import required_transform_map_contenttypes
|
|
52
56
|
from .signals import assign_primary_mac_address
|
|
53
57
|
from .utilities.ipfutils import IPFabric
|
|
54
58
|
from .utilities.ipfutils import IPFabricSyncRunner
|
|
55
59
|
from .utilities.ipfutils import render_jinja2
|
|
56
60
|
from .utilities.logging import SyncLogging
|
|
57
|
-
|
|
61
|
+
from .utilities.transform_map import has_cycle_dfs
|
|
58
62
|
|
|
59
63
|
logger = logging.getLogger("ipfabric_netbox.models")
|
|
60
64
|
|
|
@@ -98,6 +102,125 @@ IPFabricRelationshipFieldSourceModels = Q(
|
|
|
98
102
|
)
|
|
99
103
|
|
|
100
104
|
|
|
105
|
+
class IPFabricEndpoint(NetBoxModel):
|
|
106
|
+
objects = RestrictedQuerySet.as_manager()
|
|
107
|
+
|
|
108
|
+
name = models.CharField(max_length=100, unique=True)
|
|
109
|
+
description = models.TextField(blank=True, null=True)
|
|
110
|
+
endpoint = models.CharField(
|
|
111
|
+
max_length=200,
|
|
112
|
+
verbose_name=_(
|
|
113
|
+
"Endpoint path from URL notation, for example `/inventory/devices`."
|
|
114
|
+
),
|
|
115
|
+
choices=IPFabricEndpointChoices,
|
|
116
|
+
unique=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
class Meta:
|
|
120
|
+
ordering = ("pk",)
|
|
121
|
+
verbose_name = _("IP Fabric Endpoint")
|
|
122
|
+
verbose_name_plural = _("IP Fabric Endpoints")
|
|
123
|
+
|
|
124
|
+
def __str__(self):
|
|
125
|
+
return f"{self.endpoint}"
|
|
126
|
+
|
|
127
|
+
def get_absolute_url(self):
|
|
128
|
+
return reverse("plugins:ipfabric_netbox:ipfabricendpoint", args=[self.pk])
|
|
129
|
+
|
|
130
|
+
def save(self, *args, **kwargs):
|
|
131
|
+
super().save(*args, **kwargs)
|
|
132
|
+
if not self.endpoint.startswith("/"):
|
|
133
|
+
self.endpoint = f"/{self.endpoint}"
|
|
134
|
+
if self.endpoint.endswith("/"):
|
|
135
|
+
self.endpoint = self.endpoint.rstrip("/")
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _merge_filter_structures(base: dict, new: dict) -> dict:
|
|
139
|
+
"""Recursively merge filter structures with matching and/or keys at same level.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
base: Base filter dictionary to merge into
|
|
143
|
+
new: New filter dictionary to merge from
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Merged filter dictionary
|
|
147
|
+
"""
|
|
148
|
+
for key, value in new.items():
|
|
149
|
+
# Only merge 'and' and 'or' keys
|
|
150
|
+
if key not in ("and", "or") or not isinstance(value, list):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if key not in base:
|
|
154
|
+
base[key] = []
|
|
155
|
+
|
|
156
|
+
# Process each item in the new filter's array
|
|
157
|
+
for new_item in value:
|
|
158
|
+
if not isinstance(new_item, dict):
|
|
159
|
+
# Non-dict items just get appended
|
|
160
|
+
base[key].append(new_item)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Check if there's a matching structure in base to merge with
|
|
164
|
+
merged = False
|
|
165
|
+
for base_item in base[key]:
|
|
166
|
+
if not isinstance(base_item, dict):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Check if both dicts have the same and/or keys
|
|
170
|
+
new_keys = set(k for k in new_item.keys() if k in ("and", "or"))
|
|
171
|
+
base_keys = set(k for k in base_item.keys() if k in ("and", "or"))
|
|
172
|
+
|
|
173
|
+
if new_keys == base_keys and new_keys:
|
|
174
|
+
# Matching structure found - recursively merge
|
|
175
|
+
IPFabricEndpoint._merge_filter_structures(base_item, new_item)
|
|
176
|
+
merged = True
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if not merged:
|
|
180
|
+
# No matching structure found - append as new item
|
|
181
|
+
base[key].append(new_item)
|
|
182
|
+
|
|
183
|
+
return base
|
|
184
|
+
|
|
185
|
+
def combine_filters(self, sync=None) -> dict:
|
|
186
|
+
"""Combine all filters for this endpoint into a single filter dictionary.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
sync: Optional IPFabricSync to filter by. If provided, only filters
|
|
190
|
+
associated with that sync are included.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Dict with filter types as keys (e.g., 'and', 'or') and lists of
|
|
194
|
+
expressions as values.
|
|
195
|
+
"""
|
|
196
|
+
combined_filter = {}
|
|
197
|
+
|
|
198
|
+
# Get filters for this endpoint, optionally filtered by sync
|
|
199
|
+
if sync:
|
|
200
|
+
endpoint_filters = self.filters.filter(syncs=sync)
|
|
201
|
+
else:
|
|
202
|
+
endpoint_filters = self.filters.all()
|
|
203
|
+
|
|
204
|
+
for endpoint_filter in endpoint_filters:
|
|
205
|
+
filter_expressions = endpoint_filter.merge_expressions()
|
|
206
|
+
|
|
207
|
+
# Create a temporary dict with the filter type as key
|
|
208
|
+
new_filter = {endpoint_filter.filter_type: filter_expressions}
|
|
209
|
+
|
|
210
|
+
# Recursively merge the new filter into combined_filter
|
|
211
|
+
combined_filter = self._merge_filter_structures(combined_filter, new_filter)
|
|
212
|
+
|
|
213
|
+
# Sites filter is stored in sync parameters for user convenience
|
|
214
|
+
if sync and (sites := (sync.parameters or {}).get("sites")):
|
|
215
|
+
if "and" not in combined_filter:
|
|
216
|
+
combined_filter["and"] = []
|
|
217
|
+
combined_filter["and"].extend(
|
|
218
|
+
[{"or": [{"siteName": ["eq", site]} for site in sites]}]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return combined_filter
|
|
222
|
+
|
|
223
|
+
|
|
101
224
|
class IPFabricTransformMapGroup(NetBoxModel):
|
|
102
225
|
name = models.CharField(max_length=100, unique=True)
|
|
103
226
|
description = models.TextField(blank=True, null=True)
|
|
@@ -117,9 +240,12 @@ class IPFabricTransformMapGroup(NetBoxModel):
|
|
|
117
240
|
|
|
118
241
|
|
|
119
242
|
class IPFabricTransformMap(NetBoxModel):
|
|
120
|
-
name = models.CharField(max_length=
|
|
121
|
-
|
|
122
|
-
|
|
243
|
+
name = models.CharField(max_length=200)
|
|
244
|
+
source_endpoint = models.ForeignKey(
|
|
245
|
+
to=IPFabricEndpoint,
|
|
246
|
+
on_delete=models.PROTECT,
|
|
247
|
+
related_name="transform_maps",
|
|
248
|
+
editable=True,
|
|
123
249
|
)
|
|
124
250
|
target_model = models.ForeignKey(
|
|
125
251
|
to=ContentType,
|
|
@@ -138,6 +264,15 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
138
264
|
blank=True,
|
|
139
265
|
null=True,
|
|
140
266
|
)
|
|
267
|
+
parents = models.ManyToManyField(
|
|
268
|
+
"self",
|
|
269
|
+
symmetrical=False,
|
|
270
|
+
blank=True,
|
|
271
|
+
related_name="children",
|
|
272
|
+
help_text=_(
|
|
273
|
+
"Parent transform maps, for hierarchical organization during sync."
|
|
274
|
+
),
|
|
275
|
+
)
|
|
141
276
|
|
|
142
277
|
class Meta:
|
|
143
278
|
ordering = ("pk",)
|
|
@@ -145,10 +280,12 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
145
280
|
verbose_name_plural = _("IP Fabric Transform Maps")
|
|
146
281
|
|
|
147
282
|
def __str__(self):
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
283
|
+
try:
|
|
284
|
+
if self.source_endpoint and self.target_model:
|
|
285
|
+
return f"{self.source_endpoint} - {self.target_model}"
|
|
286
|
+
except (AttributeError, IPFabricEndpoint.DoesNotExist):
|
|
287
|
+
pass
|
|
288
|
+
return f"Transform Map: {self.name}" if self.name else "Transform Map"
|
|
152
289
|
|
|
153
290
|
def get_absolute_url(self):
|
|
154
291
|
return reverse("plugins:ipfabric_netbox:ipfabrictransformmap", args=[self.pk])
|
|
@@ -176,10 +313,36 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
176
313
|
"target_model": err_msg,
|
|
177
314
|
}
|
|
178
315
|
)
|
|
316
|
+
|
|
317
|
+
# Validate no circular dependencies (only if saved and has parents)
|
|
318
|
+
if self.pk:
|
|
319
|
+
self._validate_no_circular_dependency()
|
|
320
|
+
|
|
179
321
|
return cleaned_data
|
|
180
322
|
|
|
323
|
+
def _validate_no_circular_dependency(self):
|
|
324
|
+
"""
|
|
325
|
+
Check if the current parent relationships create a circular dependency.
|
|
326
|
+
Uses DFS to detect cycles in the directed graph.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
def get_parents(node_id: int, parent_override: list | None) -> models.QuerySet:
|
|
330
|
+
"""Get parents for a node."""
|
|
331
|
+
node = IPFabricTransformMap.objects.get(pk=node_id)
|
|
332
|
+
return node.parents.all()
|
|
333
|
+
|
|
334
|
+
if has_cycle_dfs(self.pk, get_parents):
|
|
335
|
+
raise ValidationError(
|
|
336
|
+
{
|
|
337
|
+
"parents": _(
|
|
338
|
+
"The selected parents create a circular dependency. "
|
|
339
|
+
"A transform map cannot be an ancestor of itself."
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
181
344
|
@functools.cache
|
|
182
|
-
def
|
|
345
|
+
def get_all_models(self):
|
|
183
346
|
_context = dict()
|
|
184
347
|
|
|
185
348
|
for app, app_models in apps.all_models.items():
|
|
@@ -194,6 +357,13 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
194
357
|
_context["contenttypes"]["ContentType"] = ContentType
|
|
195
358
|
return _context
|
|
196
359
|
|
|
360
|
+
@classmethod
|
|
361
|
+
def get_distinct_target_models(cls) -> QuerySet[ContentType]:
|
|
362
|
+
target_model_ids = IPFabricTransformMap.objects.values_list(
|
|
363
|
+
"target_model", flat=True
|
|
364
|
+
).distinct()
|
|
365
|
+
return ContentType.objects.filter(id__in=target_model_ids)
|
|
366
|
+
|
|
197
367
|
def build_relationships(self, source_data):
|
|
198
368
|
relationship_maps = self.relationship_maps.all()
|
|
199
369
|
rel_dict = {}
|
|
@@ -205,7 +375,7 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
205
375
|
context = {
|
|
206
376
|
"object": source_data,
|
|
207
377
|
}
|
|
208
|
-
context.update(self.
|
|
378
|
+
context.update(self.get_all_models())
|
|
209
379
|
text = render_jinja2(field.template, context).strip()
|
|
210
380
|
if text:
|
|
211
381
|
try:
|
|
@@ -306,7 +476,7 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
306
476
|
"object": source_data,
|
|
307
477
|
field.source_field: source_data[field.source_field],
|
|
308
478
|
}
|
|
309
|
-
context.update(self.
|
|
479
|
+
context.update(self.get_all_models())
|
|
310
480
|
text = render_jinja2(field.template, context).strip()
|
|
311
481
|
else:
|
|
312
482
|
text = source_data[field.source_field]
|
|
@@ -731,6 +901,63 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
|
|
|
731
901
|
pk__in=[tm.pk for tm in maps_by_target.values()]
|
|
732
902
|
)
|
|
733
903
|
|
|
904
|
+
@classmethod
|
|
905
|
+
def get_model_hierarchy(cls, group_ids=None) -> list[ContentType]:
|
|
906
|
+
"""
|
|
907
|
+
Get target models from transform maps in hierarchical order.
|
|
908
|
+
Uses topological sort (Kahn's algorithm) to support multiple parents.
|
|
909
|
+
Models without parents come first, then their children, etc.
|
|
910
|
+
|
|
911
|
+
Example: IP Address has parents [Interface, VRF], so it will only be
|
|
912
|
+
processed after both Interface AND VRF have been processed.
|
|
913
|
+
"""
|
|
914
|
+
maps = cls.get_transform_maps(group_ids)
|
|
915
|
+
|
|
916
|
+
# Build adjacency list and in-degree count
|
|
917
|
+
graph = {} # parent_ct -> [child_ct, ...]
|
|
918
|
+
in_degree = {} # ct -> count of unprocessed parents
|
|
919
|
+
ct_to_map = {} # ct -> transform_map (for reference)
|
|
920
|
+
|
|
921
|
+
for transform_map in maps:
|
|
922
|
+
ct = transform_map.target_model
|
|
923
|
+
ct_to_map[ct] = transform_map
|
|
924
|
+
|
|
925
|
+
# Get all parents for this transform map
|
|
926
|
+
parent_maps = transform_map.parents.all()
|
|
927
|
+
|
|
928
|
+
# Set in-degree (number of parents)
|
|
929
|
+
in_degree[ct] = parent_maps.count()
|
|
930
|
+
|
|
931
|
+
# Build adjacency list (parent -> children)
|
|
932
|
+
for parent_map in parent_maps:
|
|
933
|
+
parent_ct = parent_map.target_model
|
|
934
|
+
graph.setdefault(parent_ct, []).append(ct)
|
|
935
|
+
|
|
936
|
+
# Topological sort using Kahn's algorithm (BFS-based)
|
|
937
|
+
queue = [ct for ct, degree in in_degree.items() if degree == 0]
|
|
938
|
+
ordered = []
|
|
939
|
+
|
|
940
|
+
while queue:
|
|
941
|
+
# Pop from front to maintain BFS/level-order
|
|
942
|
+
current_ct = queue.pop(0)
|
|
943
|
+
ordered.append(current_ct)
|
|
944
|
+
|
|
945
|
+
# Reduce in-degree for all children
|
|
946
|
+
for child_ct in graph.get(current_ct, []):
|
|
947
|
+
in_degree[child_ct] -= 1
|
|
948
|
+
if in_degree[child_ct] == 0:
|
|
949
|
+
queue.append(child_ct)
|
|
950
|
+
|
|
951
|
+
# Check for circular dependencies
|
|
952
|
+
if len(ordered) != len(in_degree):
|
|
953
|
+
unprocessed = set(in_degree.keys()) - set(ordered)
|
|
954
|
+
raise ValidationError(
|
|
955
|
+
f"Circular dependency detected in transform map hierarchy. "
|
|
956
|
+
f"Unprocessed models: {', '.join(str(ct) for ct in unprocessed)}"
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
return ordered
|
|
960
|
+
|
|
734
961
|
def delete_scheduled_jobs(self) -> None:
|
|
735
962
|
Job.objects.filter(
|
|
736
963
|
object_type=ObjectType.objects.get_for_model(self),
|
|
@@ -1081,3 +1308,148 @@ class IPFabricData(models.Model):
|
|
|
1081
1308
|
|
|
1082
1309
|
def get_absolute_url(self):
|
|
1083
1310
|
return reverse("plugins:ipfabric_netbox:ipfabricdata_data", args=[self.pk])
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
class IPFabricFilter(NetBoxModel):
|
|
1314
|
+
objects = RestrictedQuerySet.as_manager()
|
|
1315
|
+
|
|
1316
|
+
name = models.CharField(max_length=100, unique=True)
|
|
1317
|
+
description = models.TextField(blank=True, null=True)
|
|
1318
|
+
endpoints = models.ManyToManyField(
|
|
1319
|
+
to=IPFabricEndpoint,
|
|
1320
|
+
related_name="filters",
|
|
1321
|
+
editable=True,
|
|
1322
|
+
default=None,
|
|
1323
|
+
blank=True,
|
|
1324
|
+
)
|
|
1325
|
+
filter_type = models.CharField(
|
|
1326
|
+
max_length=10, choices=IPFabricFilterTypeChoices, verbose_name=_("Filter Type")
|
|
1327
|
+
)
|
|
1328
|
+
syncs = models.ManyToManyField(
|
|
1329
|
+
to=IPFabricSync,
|
|
1330
|
+
related_name="filters",
|
|
1331
|
+
editable=True,
|
|
1332
|
+
default=None,
|
|
1333
|
+
blank=True,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
class Meta:
|
|
1337
|
+
ordering = ("pk",)
|
|
1338
|
+
verbose_name = _("IP Fabric Filter")
|
|
1339
|
+
verbose_name_plural = _("IP Fabric Filters")
|
|
1340
|
+
|
|
1341
|
+
def __str__(self):
|
|
1342
|
+
return self.name
|
|
1343
|
+
|
|
1344
|
+
def get_absolute_url(self):
|
|
1345
|
+
return reverse("plugins:ipfabric_netbox:ipfabricfilter", args=[self.pk])
|
|
1346
|
+
|
|
1347
|
+
def merge_expressions(self) -> list[dict]:
|
|
1348
|
+
"""Merge all linked Expressions into a single filter expression."""
|
|
1349
|
+
merged_expression = []
|
|
1350
|
+
for expression in self.expressions.all():
|
|
1351
|
+
merged_expression.extend(expression.expression)
|
|
1352
|
+
return merged_expression
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
class IPFabricFilterExpression(NetBoxModel):
|
|
1356
|
+
objects = RestrictedQuerySet.as_manager()
|
|
1357
|
+
|
|
1358
|
+
name = models.CharField(max_length=100, unique=True)
|
|
1359
|
+
description = models.TextField(blank=True, null=True)
|
|
1360
|
+
expression = models.JSONField(
|
|
1361
|
+
blank=False,
|
|
1362
|
+
null=False,
|
|
1363
|
+
default=list,
|
|
1364
|
+
verbose_name=_("IP Fabric Filter Expression JSON"),
|
|
1365
|
+
help_text=_(
|
|
1366
|
+
"JSON filter for API call to IPF, can be obtained from IPF UI call via browser developer console."
|
|
1367
|
+
),
|
|
1368
|
+
)
|
|
1369
|
+
filters = models.ManyToManyField(
|
|
1370
|
+
to=IPFabricFilter,
|
|
1371
|
+
related_name="expressions",
|
|
1372
|
+
editable=True,
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
class Meta:
|
|
1376
|
+
ordering = ("pk",)
|
|
1377
|
+
verbose_name = _("IP Fabric Filter Expression")
|
|
1378
|
+
verbose_name_plural = _("IP Fabric Filter Expressions")
|
|
1379
|
+
|
|
1380
|
+
def __str__(self):
|
|
1381
|
+
return self.name
|
|
1382
|
+
|
|
1383
|
+
def get_absolute_url(self):
|
|
1384
|
+
return reverse(
|
|
1385
|
+
"plugins:ipfabric_netbox:ipfabricfilterexpression", args=[self.pk]
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
def clean(self):
|
|
1389
|
+
super().clean()
|
|
1390
|
+
|
|
1391
|
+
# Validate that expression is a list of dictionaries
|
|
1392
|
+
if self.expression is None:
|
|
1393
|
+
raise ValidationError({"expression": _("Filter Expression is required.")})
|
|
1394
|
+
|
|
1395
|
+
if not isinstance(self.expression, list):
|
|
1396
|
+
raise ValidationError(
|
|
1397
|
+
{
|
|
1398
|
+
"expression": _("Expression must be a list. Got: %(type)s")
|
|
1399
|
+
% {"type": type(self.expression).__name__}
|
|
1400
|
+
}
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
if not self.expression:
|
|
1404
|
+
raise ValidationError(
|
|
1405
|
+
{"expression": _("Expression cannot be an empty list.")}
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
for idx, item in enumerate(self.expression):
|
|
1409
|
+
if not isinstance(item, dict):
|
|
1410
|
+
raise ValidationError(
|
|
1411
|
+
{
|
|
1412
|
+
"expression": _(
|
|
1413
|
+
"Expression item at index %(index)d must be a dictionary. Got: %(type)s"
|
|
1414
|
+
)
|
|
1415
|
+
% {"index": idx, "type": type(item).__name__}
|
|
1416
|
+
}
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
@receiver(m2m_changed, sender=IPFabricTransformMap.parents.through)
|
|
1421
|
+
def validate_circular_dependency_on_m2m_change(
|
|
1422
|
+
sender, instance, action, pk_set, **kwargs
|
|
1423
|
+
):
|
|
1424
|
+
"""
|
|
1425
|
+
Validate circular dependencies when parent M2M relationships are modified.
|
|
1426
|
+
This catches changes made through the API or programmatically.
|
|
1427
|
+
"""
|
|
1428
|
+
if action == "pre_add" and pk_set:
|
|
1429
|
+
# Simulate what the parents would be after this add operation
|
|
1430
|
+
current_parent_ids = set(instance.parents.values_list("pk", flat=True))
|
|
1431
|
+
future_parent_ids = current_parent_ids | pk_set
|
|
1432
|
+
|
|
1433
|
+
# Get the actual parent objects
|
|
1434
|
+
future_parents = IPFabricTransformMap.objects.filter(pk__in=future_parent_ids)
|
|
1435
|
+
|
|
1436
|
+
# Run cycle detection with the future parent set
|
|
1437
|
+
def get_parents(
|
|
1438
|
+
node_id: int, parent_override: models.QuerySet | None
|
|
1439
|
+
) -> models.QuerySet:
|
|
1440
|
+
"""Get parents for a node, with optional override for the instance being modified."""
|
|
1441
|
+
if node_id == instance.pk and parent_override is not None:
|
|
1442
|
+
# Use the future parents for the current node
|
|
1443
|
+
return parent_override
|
|
1444
|
+
else:
|
|
1445
|
+
# Use existing parents for other nodes
|
|
1446
|
+
node = IPFabricTransformMap.objects.get(pk=node_id)
|
|
1447
|
+
return node.parents.all()
|
|
1448
|
+
|
|
1449
|
+
if has_cycle_dfs(instance.pk, get_parents, parent_override=future_parents):
|
|
1450
|
+
raise ValidationError(
|
|
1451
|
+
_(
|
|
1452
|
+
"Cannot add these parents: circular dependency detected. "
|
|
1453
|
+
"A transform map cannot be an ancestor of itself."
|
|
1454
|
+
)
|
|
1455
|
+
)
|
ipfabric_netbox/navigation.py
CHANGED
|
@@ -3,29 +3,17 @@ from netbox.plugins import PluginMenu
|
|
|
3
3
|
from netbox.plugins import PluginMenuButton
|
|
4
4
|
from netbox.plugins import PluginMenuItem
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
sync_buttons = [
|
|
8
|
-
PluginMenuButton(
|
|
9
|
-
link="plugins:ipfabric_netbox:ipfabricsync_add",
|
|
10
|
-
title=_("Add"),
|
|
11
|
-
icon_class="mdi mdi-plus-thick",
|
|
12
|
-
permissions=["ipfabric_netbox.add_ipfabricsync"],
|
|
13
|
-
)
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
source_buttons = [
|
|
17
|
-
PluginMenuButton(
|
|
18
|
-
link="plugins:ipfabric_netbox:ipfabricsource_add",
|
|
19
|
-
title=_("Add"),
|
|
20
|
-
icon_class="mdi mdi-plus-thick",
|
|
21
|
-
permissions=["ipfabric_netbox.add_ipfabricsource"],
|
|
22
|
-
)
|
|
23
|
-
]
|
|
24
|
-
|
|
25
6
|
source = PluginMenuItem(
|
|
26
7
|
link="plugins:ipfabric_netbox:ipfabricsource_list",
|
|
27
8
|
link_text=_("Sources"),
|
|
28
|
-
buttons=
|
|
9
|
+
buttons=[
|
|
10
|
+
PluginMenuButton(
|
|
11
|
+
link="plugins:ipfabric_netbox:ipfabricsource_add",
|
|
12
|
+
title=_("Add"),
|
|
13
|
+
icon_class="mdi mdi-plus-thick",
|
|
14
|
+
permissions=["ipfabric_netbox.add_ipfabricsource"],
|
|
15
|
+
)
|
|
16
|
+
],
|
|
29
17
|
permissions=["ipfabric_netbox.view_ipfabricsource"],
|
|
30
18
|
)
|
|
31
19
|
|
|
@@ -35,14 +23,62 @@ snapshot = PluginMenuItem(
|
|
|
35
23
|
permissions=["ipfabric_netbox.view_ipfabricsnapshot"],
|
|
36
24
|
)
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
ingestion = PluginMenuItem(
|
|
26
|
+
sync = PluginMenuItem(
|
|
40
27
|
link="plugins:ipfabric_netbox:ipfabricsync_list",
|
|
41
28
|
link_text=_("Syncs"),
|
|
42
|
-
buttons=
|
|
29
|
+
buttons=[
|
|
30
|
+
PluginMenuButton(
|
|
31
|
+
link="plugins:ipfabric_netbox:ipfabricsync_add",
|
|
32
|
+
title=_("Add"),
|
|
33
|
+
icon_class="mdi mdi-plus-thick",
|
|
34
|
+
permissions=["ipfabric_netbox.add_ipfabricsync"],
|
|
35
|
+
)
|
|
36
|
+
],
|
|
43
37
|
permissions=["ipfabric_netbox.view_ipfabricsync"],
|
|
44
38
|
)
|
|
45
39
|
|
|
40
|
+
ingestion = PluginMenuItem(
|
|
41
|
+
link="plugins:ipfabric_netbox:ipfabricingestion_list",
|
|
42
|
+
link_text=_("Ingestions"),
|
|
43
|
+
buttons=[],
|
|
44
|
+
permissions=["ipfabric_netbox.view_ipfabricingestion"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
endpoint = PluginMenuItem(
|
|
48
|
+
link="plugins:ipfabric_netbox:ipfabricendpoint_list",
|
|
49
|
+
link_text=_("Endpoints"),
|
|
50
|
+
permissions=["ipfabric_netbox.view_ipfabricendpoint"],
|
|
51
|
+
buttons=[],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
filter = PluginMenuItem(
|
|
55
|
+
link="plugins:ipfabric_netbox:ipfabricfilter_list",
|
|
56
|
+
link_text=_("Filters"),
|
|
57
|
+
permissions=["ipfabric_netbox.view_ipfabricfilter"],
|
|
58
|
+
buttons=[
|
|
59
|
+
PluginMenuButton(
|
|
60
|
+
link="plugins:ipfabric_netbox:ipfabricfilter_add",
|
|
61
|
+
title=_("Add"),
|
|
62
|
+
icon_class="mdi mdi-plus-thick",
|
|
63
|
+
permissions=["ipfabric_netbox.add_ipfabricfilter"],
|
|
64
|
+
)
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
filter_expression = PluginMenuItem(
|
|
69
|
+
link="plugins:ipfabric_netbox:ipfabricfilterexpression_list",
|
|
70
|
+
link_text=_("Filter Expressions"),
|
|
71
|
+
permissions=["ipfabric_netbox.view_ipfabricfilterexpression"],
|
|
72
|
+
buttons=[
|
|
73
|
+
PluginMenuButton(
|
|
74
|
+
link="plugins:ipfabric_netbox:ipfabricfilterexpression_add",
|
|
75
|
+
title=_("Add"),
|
|
76
|
+
icon_class="mdi mdi-plus-thick",
|
|
77
|
+
permissions=["ipfabric_netbox.add_ipfabricfilterexpression"],
|
|
78
|
+
)
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
|
|
46
82
|
tmg = PluginMenuItem(
|
|
47
83
|
link="plugins:ipfabric_netbox:ipfabrictransformmapgroup_list",
|
|
48
84
|
link_text=_("Transform Map Groups"),
|
|
@@ -70,8 +106,46 @@ tm = PluginMenuItem(
|
|
|
70
106
|
)
|
|
71
107
|
],
|
|
72
108
|
)
|
|
109
|
+
|
|
110
|
+
tmf = PluginMenuItem(
|
|
111
|
+
link="plugins:ipfabric_netbox:ipfabrictransformfield_list",
|
|
112
|
+
link_text=_("Transform Fields"),
|
|
113
|
+
permissions=["ipfabric_netbox.view_ipfabrictransformfield"],
|
|
114
|
+
buttons=[
|
|
115
|
+
PluginMenuButton(
|
|
116
|
+
link="plugins:ipfabric_netbox:ipfabrictransformfield_add",
|
|
117
|
+
title=_("Add"),
|
|
118
|
+
icon_class="mdi mdi-plus-thick",
|
|
119
|
+
permissions=["ipfabric_netbox.add_ipfabrictransformfield"],
|
|
120
|
+
)
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
tmr = PluginMenuItem(
|
|
125
|
+
link="plugins:ipfabric_netbox:ipfabricrelationshipfield_list",
|
|
126
|
+
link_text=_("Relationship Fields"),
|
|
127
|
+
permissions=["ipfabric_netbox.view_ipfabricrelationshipfield"],
|
|
128
|
+
buttons=[
|
|
129
|
+
PluginMenuButton(
|
|
130
|
+
link="plugins:ipfabric_netbox:ipfabricrelationshipfield_add",
|
|
131
|
+
title=_("Add"),
|
|
132
|
+
icon_class="mdi mdi-plus-thick",
|
|
133
|
+
permissions=["ipfabric_netbox.add_ipfabricrelationshipfield"],
|
|
134
|
+
)
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
|
|
73
138
|
menu = PluginMenu(
|
|
74
139
|
label="IP Fabric",
|
|
75
140
|
icon_class="mdi mdi-cloud-sync",
|
|
76
|
-
groups=(
|
|
141
|
+
groups=(
|
|
142
|
+
(
|
|
143
|
+
"Data Sync",
|
|
144
|
+
(source, snapshot, sync, ingestion),
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"Configuration",
|
|
148
|
+
(endpoint, filter, filter_expression, tmg, tm, tmf, tmr),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
77
151
|
)
|