ipfabric_netbox 3.2.0__py3-none-any.whl → 3.2.1__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/data/transform_map.json +16 -16
- ipfabric_netbox/exceptions.py +24 -0
- ipfabric_netbox/forms.py +2 -2
- ipfabric_netbox/migrations/0009_transformmap_changes_for_netbox_v4_2.py +0 -3
- ipfabric_netbox/migrations/0010_remove_uuid_from_get_or_create.py +95 -0
- ipfabric_netbox/models.py +58 -63
- ipfabric_netbox/signals.py +29 -0
- ipfabric_netbox/tests/test_models.py +10 -26
- ipfabric_netbox/utilities/ipfutils.py +326 -211
- ipfabric_netbox/utilities/logging.py +12 -7
- ipfabric_netbox/utilities/nbutils.py +0 -26
- {ipfabric_netbox-3.2.0.dist-info → ipfabric_netbox-3.2.1.dist-info}/METADATA +3 -9
- {ipfabric_netbox-3.2.0.dist-info → ipfabric_netbox-3.2.1.dist-info}/RECORD +15 -12
- {ipfabric_netbox-3.2.0.dist-info → ipfabric_netbox-3.2.1.dist-info}/WHEEL +1 -1
ipfabric_netbox/__init__.py
CHANGED
|
@@ -5,9 +5,9 @@ class NetboxIPFabricConfig(PluginConfig):
|
|
|
5
5
|
name = "ipfabric_netbox"
|
|
6
6
|
verbose_name = "NetBox IP Fabric SoT Plugin"
|
|
7
7
|
description = "Sync IP Fabric into NetBox"
|
|
8
|
-
version = "3.2.
|
|
8
|
+
version = "3.2.1"
|
|
9
9
|
base_url = "ipfabric"
|
|
10
|
-
min_version = "4.2"
|
|
10
|
+
min_version = "4.2.4"
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
config = NetboxIPFabricConfig
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"target_field": "vrf",
|
|
45
45
|
"coalesce": false,
|
|
46
|
-
"template": "{{ ipam.VRF.objects.filter(name=object.vrf).first().pk }}"
|
|
46
|
+
"template": "{% if object.vrf is defined and object.vrf | string not in [\"\", \"system\", \"0\"] %}{{ ipam.VRF.objects.filter(name=object.vrf).first().pk }}{% else %}None{% endif %}"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
},
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"target_field": "manufacturer",
|
|
84
84
|
"coalesce": false,
|
|
85
|
-
"template":
|
|
85
|
+
"template": "{% set SLUG = object.vendor | slugify %}\n{{ dcim.Manufacturer.objects.get(slug=SLUG).pk }}"
|
|
86
86
|
}
|
|
87
87
|
]
|
|
88
88
|
},
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
},
|
|
195
195
|
"target_field": "platform",
|
|
196
196
|
"coalesce": false,
|
|
197
|
-
"template":
|
|
197
|
+
"template": "{% set SLUG = object.vendor | slugify %}{% if object.family %}{% set SLUG = SLUG ~ \"_\" ~ object.family | slugify %}{% endif %}\n{{ dcim.Platform.objects.get(slug=SLUG).pk }}"
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
"source_model": {
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
},
|
|
204
204
|
"target_field": "site",
|
|
205
205
|
"coalesce": false,
|
|
206
|
-
"template":
|
|
206
|
+
"template": "{% if object.siteName is defined %}{% set SLUG = object.siteName | slugify %}\n{{ dcim.Site.objects.get(slug=SLUG).pk }}{% else %}None{% endif %}"
|
|
207
207
|
},
|
|
208
208
|
{
|
|
209
209
|
"source_model": {
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
},
|
|
213
213
|
"target_field": "device_type",
|
|
214
214
|
"coalesce": false,
|
|
215
|
-
"template":
|
|
215
|
+
"template": "{% if object.model != none %}{% set SLUG = object.model | string | slugify %}{% else %}{% set SLUG = object.vendor | slugify ~ \"-\" ~ object.family | slugify ~ \"-\" ~ object.platform %}{% endif %}{{ dcim.DeviceType.objects.get(slug=SLUG).pk }}"
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
"source_model": {
|
|
@@ -221,7 +221,7 @@
|
|
|
221
221
|
},
|
|
222
222
|
"target_field": "role",
|
|
223
223
|
"coalesce": false,
|
|
224
|
-
"template":
|
|
224
|
+
"template": "{% if object.devType is defined %}{% set SLUG = object.devType | slugify %}{{ dcim.DeviceRole.objects.get(slug=SLUG).pk }}{% endif %}"
|
|
225
225
|
}
|
|
226
226
|
]
|
|
227
227
|
},
|
|
@@ -295,7 +295,7 @@
|
|
|
295
295
|
},
|
|
296
296
|
"target_field": "manufacturer",
|
|
297
297
|
"coalesce": false,
|
|
298
|
-
"template":
|
|
298
|
+
"template": "{% set SLUG = object.vendor | slugify %}\n{{ dcim.Manufacturer.objects.get(slug=SLUG).pk }}"
|
|
299
299
|
}
|
|
300
300
|
]
|
|
301
301
|
},
|
|
@@ -370,7 +370,7 @@
|
|
|
370
370
|
},
|
|
371
371
|
"target_field": "device",
|
|
372
372
|
"coalesce": true,
|
|
373
|
-
"template":
|
|
373
|
+
"template": "{{ dcim.Device.objects.get(serial=object.sn).pk }}"
|
|
374
374
|
}
|
|
375
375
|
]
|
|
376
376
|
},
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
"source_field": "pid",
|
|
432
432
|
"target_field": "part_id",
|
|
433
433
|
"coalesce": false,
|
|
434
|
-
"template": ""
|
|
434
|
+
"template": "{% if object.pid and object.pid != \"None\" %}{{ object.pid }}{% else %}unknown{% endif %}"
|
|
435
435
|
},
|
|
436
436
|
{
|
|
437
437
|
"source_field": "sn",
|
|
@@ -454,7 +454,7 @@
|
|
|
454
454
|
},
|
|
455
455
|
"target_field": "device",
|
|
456
456
|
"coalesce": false,
|
|
457
|
-
"template": "{{ dcim.Device.objects.
|
|
457
|
+
"template": "{{ dcim.Device.objects.get(serial=object.deviceSn).pk }}"
|
|
458
458
|
},
|
|
459
459
|
{
|
|
460
460
|
"source_model": {
|
|
@@ -463,7 +463,7 @@
|
|
|
463
463
|
},
|
|
464
464
|
"target_field": "manufacturer",
|
|
465
465
|
"coalesce": false,
|
|
466
|
-
"template":
|
|
466
|
+
"template": "{% if object.vendor is defined %}{% set SLUG = object.vendor | slugify %}\n{{ dcim.Manufacturer.objects.get(slug=SLUG).pk }}{% endif %}"
|
|
467
467
|
}
|
|
468
468
|
]
|
|
469
469
|
},
|
|
@@ -485,7 +485,7 @@
|
|
|
485
485
|
"source_field": "vlanName",
|
|
486
486
|
"target_field": "name",
|
|
487
487
|
"coalesce": false,
|
|
488
|
-
"template": "{
|
|
488
|
+
"template": "{% if object.vlanName is defined and object.vlanName | lower != \"none\" %}{{ object.vlanName | string | truncate(64, True) }}{% else %}\"\"{% endif %}"
|
|
489
489
|
},
|
|
490
490
|
{
|
|
491
491
|
"source_field": "dscr",
|
|
@@ -508,7 +508,7 @@
|
|
|
508
508
|
},
|
|
509
509
|
"target_field": "site",
|
|
510
510
|
"coalesce": true,
|
|
511
|
-
"template":
|
|
511
|
+
"template": "{% if object.siteName is defined %}{% set SLUG = object.siteName | slugify %}\n{{ dcim.Site.objects.get(slug=SLUG).pk }}{% else %}None{% endif %}"
|
|
512
512
|
}
|
|
513
513
|
]
|
|
514
514
|
},
|
|
@@ -565,7 +565,7 @@
|
|
|
565
565
|
"source_field": "siteName",
|
|
566
566
|
"target_field": "scope_id",
|
|
567
567
|
"coalesce": true,
|
|
568
|
-
"template": "{% if object.siteName is defined %}{% set SLUG = object.siteName | slugify %}\n{{ dcim.Site.objects.
|
|
568
|
+
"template": "{% if object.siteName is defined %}{% set SLUG = object.siteName | slugify %}\n{{ dcim.Site.objects.get(slug=SLUG).pk }}{% else %}None{% endif %}"
|
|
569
569
|
}
|
|
570
570
|
],
|
|
571
571
|
"relationship_maps": [
|
|
@@ -576,7 +576,7 @@
|
|
|
576
576
|
},
|
|
577
577
|
"target_field": "vrf",
|
|
578
578
|
"coalesce": true,
|
|
579
|
-
"template": "{% if object.vrf %}{{ ipam.VRF.objects.filter(name=object.vrf).first().pk }}{% else %}None{% endif %}"
|
|
579
|
+
"template": "{% if object.vrf is defined and object.vrf | string not in [\"\", \"system\", \"0\"] %}{{ ipam.VRF.objects.filter(name=object.vrf).first().pk }}{% else %}None{% endif %}"
|
|
580
580
|
},
|
|
581
581
|
{
|
|
582
582
|
"source_model": {
|
|
@@ -618,7 +618,7 @@
|
|
|
618
618
|
},
|
|
619
619
|
"target_field": "master",
|
|
620
620
|
"coalesce": false,
|
|
621
|
-
"template": "{
|
|
621
|
+
"template": "{% set DEVICE = dcim.Device.objects.filter(serial=object.sn).first() %}{% if DEVICE %}{{ DEVICE.pk }}{% endif %}"
|
|
622
622
|
}
|
|
623
623
|
]
|
|
624
624
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from core.exceptions import SyncError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ErrorMixin(Exception):
|
|
5
|
+
model: str = ""
|
|
6
|
+
defaults: dict[str, str] = {}
|
|
7
|
+
coalesce_fields: dict[str, str] = {}
|
|
8
|
+
|
|
9
|
+
def __init__(self, model: str, context: dict, data: dict = None):
|
|
10
|
+
super().__init__()
|
|
11
|
+
self.model = model
|
|
12
|
+
self.data = data or {}
|
|
13
|
+
self.defaults = context.pop("defaults", {})
|
|
14
|
+
self.coalesce_fields = context
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SearchError(LookupError, ErrorMixin):
|
|
18
|
+
def __str__(self):
|
|
19
|
+
return f"{self.model} with these keys not found: {self.coalesce_fields}."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncDataError(SyncError, ErrorMixin):
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return f"Sync failed for {self.model}: coalesce_fields={self.coalesce_fields} defaults={self.defaults}."
|
ipfabric_netbox/forms.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
|
|
3
3
|
from core.choices import DataSourceStatusChoices
|
|
4
|
+
from core.choices import JobIntervalChoices
|
|
4
5
|
from django import forms
|
|
5
6
|
from django.contrib.contenttypes.models import ContentType
|
|
6
7
|
from django.core.exceptions import ValidationError
|
|
7
8
|
from django.utils import timezone
|
|
8
9
|
from django.utils.translation import gettext_lazy as _
|
|
9
|
-
from extras.choices import DurationChoices
|
|
10
10
|
from netbox.forms import NetBoxModelFilterSetForm
|
|
11
11
|
from netbox.forms import NetBoxModelForm
|
|
12
12
|
from netbox.forms.mixins import SavedFiltersMixin
|
|
@@ -476,7 +476,7 @@ class IPFabricSyncForm(NetBoxModelForm):
|
|
|
476
476
|
required=False,
|
|
477
477
|
min_value=1,
|
|
478
478
|
label=_("Recurs every"),
|
|
479
|
-
widget=NumberWithOptions(options=
|
|
479
|
+
widget=NumberWithOptions(options=JobIntervalChoices),
|
|
480
480
|
help_text=_("Interval at which this sync is re-run (in minutes)"),
|
|
481
481
|
)
|
|
482
482
|
auto_merge = forms.BooleanField(
|
|
@@ -41,9 +41,6 @@ def get_current_map_field(apps: "apps", target_model: str) -> "IPFabricTransform
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
# TODO: Add MAC Address to existing IPFabricSync instances if Interface is allowed
|
|
45
|
-
|
|
46
|
-
|
|
47
44
|
def create_transform_map(apps: "apps", schema_editor: "BaseDatabaseSchemaEditor"):
|
|
48
45
|
"""Create transform map structure for MAC Address (new in NetBox v4.2).
|
|
49
46
|
Keeps current template for MAC Address if it exists.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
from ipfabric_netbox.utilities.transform_map import get_transform_map
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
items_to_change = {
|
|
13
|
+
("ip_address", "ipam", "ipaddress"): {"fields": [], "relationships": ["vrf"]},
|
|
14
|
+
("device", "dcim", "platform"): {"fields": [], "relationships": ["manufacturer"]},
|
|
15
|
+
("device", "dcim", "device"): {
|
|
16
|
+
"fields": [],
|
|
17
|
+
"relationships": ["platform", "site", "device_type", "role"],
|
|
18
|
+
},
|
|
19
|
+
("device", "dcim", "devicetype"): {"fields": [], "relationships": ["manufacturer"]},
|
|
20
|
+
("interface", "dcim", "interface"): {"fields": [], "relationships": ["device"]},
|
|
21
|
+
("part_number", "dcim", "inventoryitem"): {
|
|
22
|
+
"fields": ["part_id"],
|
|
23
|
+
"relationships": ["device", "manufacturer"],
|
|
24
|
+
},
|
|
25
|
+
("vlan", "ipam", "vlan"): {"fields": ["name"], "relationships": ["site"]},
|
|
26
|
+
("prefix", "ipam", "prefix"): {"fields": ["scope_id"], "relationships": ["vrf"]},
|
|
27
|
+
("virtualchassis", "dcim", "virtualchassis"): {
|
|
28
|
+
"fields": [],
|
|
29
|
+
"relationships": ["master"],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_templates(apps: "apps", schema_editor: "BaseDatabaseSchemaEditor"):
|
|
35
|
+
transform_map_data = get_transform_map()
|
|
36
|
+
|
|
37
|
+
for item in transform_map_data:
|
|
38
|
+
transform_map_id = (
|
|
39
|
+
item["data"]["source_model"],
|
|
40
|
+
item["data"]["target_model"]["app_label"],
|
|
41
|
+
item["data"]["target_model"]["model"],
|
|
42
|
+
)
|
|
43
|
+
if transform_map_id not in items_to_change:
|
|
44
|
+
continue
|
|
45
|
+
source_model, app_label, target_model = transform_map_id
|
|
46
|
+
transform_map = apps.get_model(
|
|
47
|
+
"ipfabric_netbox", "IPFabricTransformMap"
|
|
48
|
+
).objects.get(
|
|
49
|
+
source_model=source_model,
|
|
50
|
+
target_model=apps.get_model("contenttypes", "ContentType").objects.get(
|
|
51
|
+
app_label=app_label,
|
|
52
|
+
model=target_model,
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
change_data = items_to_change[transform_map_id]
|
|
57
|
+
|
|
58
|
+
for field_map in item["field_maps"]:
|
|
59
|
+
if field_map["target_field"] not in change_data["fields"]:
|
|
60
|
+
continue
|
|
61
|
+
field = apps.get_model(
|
|
62
|
+
"ipfabric_netbox", "IPFabricTransformField"
|
|
63
|
+
).objects.get(
|
|
64
|
+
target_field=field_map["target_field"], transform_map=transform_map
|
|
65
|
+
)
|
|
66
|
+
field.template = field_map["template"]
|
|
67
|
+
field.save()
|
|
68
|
+
|
|
69
|
+
for relationship_map in item["relationship_maps"]:
|
|
70
|
+
if relationship_map["target_field"] not in change_data["relationships"]:
|
|
71
|
+
continue
|
|
72
|
+
relationship = apps.get_model(
|
|
73
|
+
"ipfabric_netbox", "IPFabricRelationshipField"
|
|
74
|
+
).objects.get(
|
|
75
|
+
target_field=relationship_map["target_field"],
|
|
76
|
+
transform_map=transform_map,
|
|
77
|
+
)
|
|
78
|
+
relationship.template = relationship_map["template"]
|
|
79
|
+
relationship.save()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def return_templates(apps: "apps", schema_editor: "BaseDatabaseSchemaEditor"):
|
|
83
|
+
"""It would be way too complex to revert this migration and there is no need to do so.
|
|
84
|
+
The original code works the same with or without the templates."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Migration(migrations.Migration):
|
|
89
|
+
dependencies = [
|
|
90
|
+
("ipfabric_netbox", "0009_transformmap_changes_for_netbox_v4_2"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
operations = [
|
|
94
|
+
migrations.RunPython(add_templates, return_templates),
|
|
95
|
+
]
|
ipfabric_netbox/models.py
CHANGED
|
@@ -10,6 +10,7 @@ from core.exceptions import SyncError
|
|
|
10
10
|
from core.models import Job
|
|
11
11
|
from core.signals import handle_deleted_object
|
|
12
12
|
from core.signals import pre_sync
|
|
13
|
+
from dcim.models import Device
|
|
13
14
|
from dcim.models import VirtualChassis
|
|
14
15
|
from dcim.signals import assign_virtualchassis_master
|
|
15
16
|
from django.apps import apps
|
|
@@ -21,6 +22,7 @@ from django.db import models
|
|
|
21
22
|
from django.db import transaction
|
|
22
23
|
from django.db.models import Q
|
|
23
24
|
from django.db.models import signals
|
|
25
|
+
from django.forms.models import model_to_dict
|
|
24
26
|
from django.urls import reverse
|
|
25
27
|
from django.utils import timezone
|
|
26
28
|
from django.utils.module_loading import import_string
|
|
@@ -43,6 +45,7 @@ from .choices import IPFabricSnapshotStatusModelChoices
|
|
|
43
45
|
from .choices import IPFabricSourceTypeChoices
|
|
44
46
|
from .choices import IPFabricSyncTypeChoices
|
|
45
47
|
from .choices import IPFabricTransformMapSourceModelChoices
|
|
48
|
+
from .signals import clear_other_primary_ip
|
|
46
49
|
from .utilities.ipfutils import IPFabric
|
|
47
50
|
from .utilities.ipfutils import IPFabricSyncRunner
|
|
48
51
|
from .utilities.ipfutils import render_jinja2
|
|
@@ -139,85 +142,60 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
139
142
|
_context["contenttypes"]["ContentType"] = ContentType
|
|
140
143
|
return _context
|
|
141
144
|
|
|
142
|
-
def build_relationships(self,
|
|
145
|
+
def build_relationships(self, source_data):
|
|
143
146
|
relationship_maps = self.relationship_maps.all()
|
|
144
147
|
rel_dict = {}
|
|
145
148
|
rel_dict_coalesce = {}
|
|
146
149
|
|
|
147
150
|
for field in relationship_maps:
|
|
148
|
-
if field.template:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
related_object = ast.literal_eval(pk)
|
|
151
|
+
if not field.template:
|
|
152
|
+
continue
|
|
153
|
+
context = {
|
|
154
|
+
"object": source_data,
|
|
155
|
+
}
|
|
156
|
+
context.update(self.get_models())
|
|
157
|
+
text = render_jinja2(field.template, context).strip()
|
|
158
|
+
if text:
|
|
159
|
+
try:
|
|
160
|
+
pk = int(text)
|
|
161
|
+
except ValueError:
|
|
162
|
+
pk = text
|
|
163
|
+
|
|
164
|
+
if isinstance(pk, int):
|
|
165
|
+
related_object = field.source_model.model_class().objects.get(pk=pk)
|
|
166
|
+
else:
|
|
167
|
+
related_object = ast.literal_eval(pk)
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
if not field.coalesce:
|
|
170
|
+
rel_dict[field.target_field] = related_object
|
|
171
|
+
else:
|
|
172
|
+
if related_object is None:
|
|
173
|
+
# We are searching by this field, so we need to set it to None
|
|
174
|
+
rel_dict_coalesce[field.target_field + "__isnull"] = True
|
|
169
175
|
else:
|
|
170
176
|
rel_dict_coalesce[field.target_field] = related_object
|
|
171
|
-
elif uuid and self.relationship_store.get(uuid):
|
|
172
|
-
object = self.relationship_store[uuid].get(
|
|
173
|
-
field.source_model.model_class()
|
|
174
|
-
)
|
|
175
|
-
if object:
|
|
176
|
-
if not field.coalesce:
|
|
177
|
-
rel_dict[field.target_field] = object
|
|
178
|
-
else:
|
|
179
|
-
rel_dict_coalesce[field.target_field] = object
|
|
180
|
-
|
|
181
177
|
return rel_dict, rel_dict_coalesce
|
|
182
178
|
|
|
183
|
-
def
|
|
184
|
-
|
|
185
|
-
):
|
|
186
|
-
self.relationship_store = relationship_store
|
|
187
|
-
new_data = deepcopy(data)
|
|
179
|
+
def get_context(self, source_data):
|
|
180
|
+
new_data = deepcopy(source_data)
|
|
188
181
|
relationship, coalesce_relationship = self.build_relationships(
|
|
189
|
-
|
|
182
|
+
source_data=source_data
|
|
190
183
|
)
|
|
191
184
|
if relationship:
|
|
192
185
|
new_data["relationship"] = relationship
|
|
193
186
|
if coalesce_relationship:
|
|
194
187
|
new_data["relationship_coalesce"] = coalesce_relationship
|
|
195
188
|
context = self.render(new_data)
|
|
196
|
-
|
|
197
|
-
instance, _ = self.target_model.model_class().objects.update_or_create(
|
|
198
|
-
**context
|
|
199
|
-
)
|
|
200
|
-
if instance:
|
|
201
|
-
apply_tags(instance, tags)
|
|
202
|
-
except Exception as e:
|
|
203
|
-
error_message = f"""Failed to create instance of `{str(self.target_model)}`:<br/>
|
|
204
|
-
message: `{e}`<br/>
|
|
205
|
-
raw data: `{data}`<br/>
|
|
206
|
-
context: `{context}`<br/>
|
|
207
|
-
""" # noqa E231 E222
|
|
208
|
-
logger.log_failure(error_message, obj=self)
|
|
209
|
-
logger.log_failure(
|
|
210
|
-
"Ensure that all transform map fields are present.", obj=self
|
|
211
|
-
)
|
|
212
|
-
raise SyncError("Unable to update_or_create_instance.")
|
|
189
|
+
return context
|
|
213
190
|
|
|
191
|
+
def update_or_create_instance(self, context, tags=[]):
|
|
192
|
+
instance, _ = self.target_model.model_class().objects.update_or_create(
|
|
193
|
+
**context
|
|
194
|
+
)
|
|
195
|
+
if instance:
|
|
196
|
+
apply_tags(instance, tags)
|
|
214
197
|
return instance
|
|
215
198
|
|
|
216
|
-
def get_coalesce_fields(self, source_data):
|
|
217
|
-
data = self.render(source_data)
|
|
218
|
-
del data["defaults"]
|
|
219
|
-
return data
|
|
220
|
-
|
|
221
199
|
def render(self, source_data):
|
|
222
200
|
data = {"defaults": {}}
|
|
223
201
|
for field in self.field_maps.all():
|
|
@@ -251,7 +229,10 @@ class IPFabricTransformMap(NetBoxModel):
|
|
|
251
229
|
if not field.coalesce:
|
|
252
230
|
data["defaults"][field.target_field] = text
|
|
253
231
|
else:
|
|
254
|
-
|
|
232
|
+
if text is None:
|
|
233
|
+
data[field.target_field + "__isnull"] = True
|
|
234
|
+
else:
|
|
235
|
+
data[field.target_field] = text
|
|
255
236
|
|
|
256
237
|
if relationship := source_data.get("relationship"):
|
|
257
238
|
data["defaults"].update(relationship)
|
|
@@ -720,9 +701,11 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
|
|
|
720
701
|
)
|
|
721
702
|
|
|
722
703
|
with checkout(branch):
|
|
723
|
-
runner.
|
|
704
|
+
runner.collect_and_sync(branch=branch)
|
|
705
|
+
|
|
706
|
+
if self.status != DataSourceStatusChoices.FAILED:
|
|
707
|
+
self.status = DataSourceStatusChoices.COMPLETED
|
|
724
708
|
|
|
725
|
-
self.status = DataSourceStatusChoices.COMPLETED
|
|
726
709
|
except Exception as e:
|
|
727
710
|
self.status = DataSourceStatusChoices.FAILED
|
|
728
711
|
self.logger.log_failure(f"Branch Failed: `{e}`", obj=branch)
|
|
@@ -818,6 +801,8 @@ class IPFabricBranch(JobsMixin, Branch):
|
|
|
818
801
|
statistics = {}
|
|
819
802
|
if job_results:
|
|
820
803
|
for model, stats in job_results["statistics"].items():
|
|
804
|
+
if not stats["total"]:
|
|
805
|
+
continue
|
|
821
806
|
if stats["total"] > 0:
|
|
822
807
|
statistics[model] = stats["current"] / stats["total"] * 100
|
|
823
808
|
else:
|
|
@@ -829,7 +814,15 @@ class IPFabricBranch(JobsMixin, Branch):
|
|
|
829
814
|
with transaction.atomic():
|
|
830
815
|
for change in self.staged_changes.all():
|
|
831
816
|
logger.debug("Applying change: %s", change)
|
|
832
|
-
|
|
817
|
+
try:
|
|
818
|
+
change.apply()
|
|
819
|
+
except Exception as err:
|
|
820
|
+
content_type = ContentType.objects.get(pk=change.object_type.pk)
|
|
821
|
+
data = model_to_dict(change)["data"]
|
|
822
|
+
logger.error(
|
|
823
|
+
f"Got error applying change ({content_type}: {data}): {err}"
|
|
824
|
+
)
|
|
825
|
+
raise
|
|
833
826
|
signals.pre_delete.disconnect(handle_deleted_object)
|
|
834
827
|
self.staged_changes.all().delete()
|
|
835
828
|
signals.pre_delete.connect(handle_deleted_object, sender=StagedChange)
|
|
@@ -849,6 +842,7 @@ class IPFabricBranch(JobsMixin, Branch):
|
|
|
849
842
|
# Begin Sync
|
|
850
843
|
logger.debug(f"Merging {self.name}")
|
|
851
844
|
try:
|
|
845
|
+
signals.pre_save.connect(clear_other_primary_ip, sender=Device)
|
|
852
846
|
signals.post_save.disconnect(
|
|
853
847
|
assign_virtualchassis_master, sender=VirtualChassis
|
|
854
848
|
)
|
|
@@ -856,6 +850,7 @@ class IPFabricBranch(JobsMixin, Branch):
|
|
|
856
850
|
signals.post_save.connect(
|
|
857
851
|
assign_virtualchassis_master, sender=VirtualChassis
|
|
858
852
|
)
|
|
853
|
+
signals.pre_save.disconnect(clear_other_primary_ip, sender=Device)
|
|
859
854
|
ipfabricsync.status = DataSourceStatusChoices.COMPLETED
|
|
860
855
|
except Exception as e:
|
|
861
856
|
ipfabricsync.status = DataSourceStatusChoices.FAILED
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from dcim.models import Device
|
|
4
|
+
from ipam.models import IPAddress
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("ipfabric_netbox.utilities.ipf_utils")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def clear_other_primary_ip(instance: Device, **kwargs) -> None:
|
|
10
|
+
"""
|
|
11
|
+
When a new device is created with primary IP, make sure there is no other device with the same IP.
|
|
12
|
+
|
|
13
|
+
This signal is used when merging stashed changes. It's needed because we cannot
|
|
14
|
+
guarantee that removing primary IP from Device will happen before adding new one.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
if not instance.primary_ip:
|
|
18
|
+
# The device has no primary IP, nothing to do
|
|
19
|
+
return
|
|
20
|
+
except IPAddress.DoesNotExist:
|
|
21
|
+
# THe IP is not created yet, cannot be assigned
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
other_device = Device.objects.get(primary_ip4=instance.primary_ip)
|
|
25
|
+
if other_device and instance != other_device:
|
|
26
|
+
other_device.primary_ip4 = None
|
|
27
|
+
other_device.save()
|
|
28
|
+
except Device.DoesNotExist:
|
|
29
|
+
pass
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
import json
|
|
3
|
-
import uuid
|
|
4
3
|
|
|
5
4
|
from dcim.models import Device
|
|
6
5
|
from django.contrib.contenttypes.models import ContentType
|
|
@@ -155,10 +154,9 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
155
154
|
},
|
|
156
155
|
sync=sync,
|
|
157
156
|
)
|
|
158
|
-
device_uuid = str(uuid.uuid4())
|
|
159
157
|
|
|
160
158
|
site_data = {
|
|
161
|
-
"siteName": "
|
|
159
|
+
"siteName": "MPLS",
|
|
162
160
|
"devicesCount": 1,
|
|
163
161
|
"usersCount": 2,
|
|
164
162
|
"stpDCount": 0,
|
|
@@ -169,9 +167,7 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
169
167
|
"networksCount": 6,
|
|
170
168
|
}
|
|
171
169
|
|
|
172
|
-
self.site = runner.get_model_or_update(
|
|
173
|
-
"dcim", "site", site_data, uuid=device_uuid
|
|
174
|
-
)
|
|
170
|
+
self.site = runner.get_model_or_update("dcim", "site", site_data)
|
|
175
171
|
|
|
176
172
|
device_data = {
|
|
177
173
|
"id": "961251111",
|
|
@@ -209,24 +205,14 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
209
205
|
"slug": None,
|
|
210
206
|
}
|
|
211
207
|
|
|
212
|
-
self.mf_obj = runner.get_model_or_update(
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
self.dt_obj = runner.get_model_or_update(
|
|
216
|
-
"dcim", "devicetype", device_data, uuid=device_uuid
|
|
217
|
-
)
|
|
208
|
+
self.mf_obj = runner.get_model_or_update("dcim", "manufacturer", device_data)
|
|
209
|
+
self.dt_obj = runner.get_model_or_update("dcim", "devicetype", device_data)
|
|
218
210
|
|
|
219
|
-
self.platform = runner.get_model_or_update(
|
|
220
|
-
"dcim", "platform", device_data, uuid=device_uuid
|
|
221
|
-
)
|
|
211
|
+
self.platform = runner.get_model_or_update("dcim", "platform", device_data)
|
|
222
212
|
|
|
223
|
-
self.role = runner.get_model_or_update(
|
|
224
|
-
"dcim", "devicerole", device_data, uuid=device_uuid
|
|
225
|
-
)
|
|
213
|
+
self.role = runner.get_model_or_update("dcim", "devicerole", device_data)
|
|
226
214
|
|
|
227
|
-
self.device_object = runner.get_model_or_update(
|
|
228
|
-
"dcim", "device", device_data, uuid=device_uuid
|
|
229
|
-
)
|
|
215
|
+
self.device_object = runner.get_model_or_update("dcim", "device", device_data)
|
|
230
216
|
|
|
231
217
|
def test_transform_map(self):
|
|
232
218
|
site_transform_map = IPFabricTransformMap.objects.get(name="Site Transform Map")
|
|
@@ -318,7 +304,8 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
318
304
|
"routersCount": 0,
|
|
319
305
|
"networksCount": 6,
|
|
320
306
|
}
|
|
321
|
-
|
|
307
|
+
context = site_transform_map.get_context(data)
|
|
308
|
+
object = site_transform_map.update_or_create_instance(context)
|
|
322
309
|
self.assertEqual(object.name, "Site 1")
|
|
323
310
|
self.assertEqual(object.slug, "site-1")
|
|
324
311
|
|
|
@@ -353,7 +340,6 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
353
340
|
},
|
|
354
341
|
sync=sync,
|
|
355
342
|
)
|
|
356
|
-
device_uuid = str(uuid.uuid4())
|
|
357
343
|
|
|
358
344
|
device_data = {
|
|
359
345
|
"id": "961251111",
|
|
@@ -401,7 +387,5 @@ class IPFabricTransformMapModelTestCase(TestCase):
|
|
|
401
387
|
)
|
|
402
388
|
transform_field.template = "{{ object.hostname }} - test"
|
|
403
389
|
transform_field.save()
|
|
404
|
-
device_object = runner.get_model_or_update(
|
|
405
|
-
"dcim", "device", device_data, uuid=device_uuid
|
|
406
|
-
)
|
|
390
|
+
device_object = runner.get_model_or_update("dcim", "device", device_data)
|
|
407
391
|
self.assertEqual(device_object.name, "L21PE152 - test")
|