ipfabric_netbox 3.1.3__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.
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
@@ -71,6 +74,7 @@ IPFabricSupportedSyncModels = Q(
71
74
  | Q(app_label="dcim", model="device")
72
75
  | Q(app_label="dcim", model="virtualchassis")
73
76
  | Q(app_label="dcim", model="interface")
77
+ | Q(app_label="dcim", model="macaddress")
74
78
  | Q(app_label="ipam", model="vlan")
75
79
  | Q(app_label="ipam", model="vrf")
76
80
  | Q(app_label="ipam", model="prefix")
@@ -138,85 +142,60 @@ class IPFabricTransformMap(NetBoxModel):
138
142
  _context["contenttypes"]["ContentType"] = ContentType
139
143
  return _context
140
144
 
141
- def build_relationships(self, uuid, source_data):
145
+ def build_relationships(self, source_data):
142
146
  relationship_maps = self.relationship_maps.all()
143
147
  rel_dict = {}
144
148
  rel_dict_coalesce = {}
145
149
 
146
150
  for field in relationship_maps:
147
- if field.template:
148
- context = {
149
- "object": source_data,
150
- }
151
- context.update(self.get_models())
152
- text = render_jinja2(field.template, context).strip()
153
- if text:
154
- try:
155
- pk = int(text)
156
- except ValueError:
157
- pk = text
158
-
159
- if isinstance(pk, int):
160
- related_object = field.source_model.model_class().objects.get(
161
- pk=pk
162
- )
163
- else:
164
- 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)
165
168
 
166
- if not field.coalesce:
167
- rel_dict[field.target_field] = related_object
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
168
175
  else:
169
176
  rel_dict_coalesce[field.target_field] = related_object
170
- elif uuid and self.relationship_store.get(uuid):
171
- object = self.relationship_store[uuid].get(
172
- field.source_model.model_class()
173
- )
174
- if object:
175
- if not field.coalesce:
176
- rel_dict[field.target_field] = object
177
- else:
178
- rel_dict_coalesce[field.target_field] = object
179
-
180
177
  return rel_dict, rel_dict_coalesce
181
178
 
182
- def update_or_create_instance(
183
- self, data, tags=[], uuid=None, relationship_store={}, logger=None
184
- ):
185
- self.relationship_store = relationship_store
186
- new_data = deepcopy(data)
179
+ def get_context(self, source_data):
180
+ new_data = deepcopy(source_data)
187
181
  relationship, coalesce_relationship = self.build_relationships(
188
- uuid=uuid, source_data=data
182
+ source_data=source_data
189
183
  )
190
184
  if relationship:
191
185
  new_data["relationship"] = relationship
192
186
  if coalesce_relationship:
193
187
  new_data["relationship_coalesce"] = coalesce_relationship
194
188
  context = self.render(new_data)
195
- try:
196
- instance, _ = self.target_model.model_class().objects.update_or_create(
197
- **context
198
- )
199
- if instance:
200
- apply_tags(instance, tags)
201
- except Exception as e:
202
- error_message = f"""Failed to create instance:<br/>
203
- message: `{e}`<br/>
204
- raw data: `{data}`<br/>
205
- context: `{context}`<br/>
206
- """ # noqa E231 E222
207
- logger.log_failure(error_message, obj=self)
208
- logger.log_failure(
209
- "Ensure that all transform map fields are present.", obj=self
210
- )
211
- raise SyncError("Unable to update_or_create_instance.")
189
+ return context
212
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)
213
197
  return instance
214
198
 
215
- def get_coalesce_fields(self, source_data):
216
- data = self.render(source_data)
217
- del data["defaults"]
218
- return data
219
-
220
199
  def render(self, source_data):
221
200
  data = {"defaults": {}}
222
201
  for field in self.field_maps.all():
@@ -250,7 +229,10 @@ class IPFabricTransformMap(NetBoxModel):
250
229
  if not field.coalesce:
251
230
  data["defaults"][field.target_field] = text
252
231
  else:
253
- data[field.target_field] = text
232
+ if text is None:
233
+ data[field.target_field + "__isnull"] = True
234
+ else:
235
+ data[field.target_field] = text
254
236
 
255
237
  if relationship := source_data.get("relationship"):
256
238
  data["defaults"].update(relationship)
@@ -597,6 +579,14 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
597
579
  # TODO: Add docs url
598
580
  return ""
599
581
 
582
+ @property
583
+ def logger(self):
584
+ return getattr(self, "_logger", SyncLogging(job=self.pk))
585
+
586
+ @logger.setter
587
+ def logger(self, value):
588
+ self._logger = value
589
+
600
590
  def get_absolute_url(self):
601
591
  return reverse("plugins:ipfabric_netbox:ipfabricsync", args=[self.pk])
602
592
 
@@ -711,9 +701,11 @@ class IPFabricSync(IPFabricClient, JobsMixin, TagsMixin, ChangeLoggedModel):
711
701
  )
712
702
 
713
703
  with checkout(branch):
714
- runner.sync_devices(branch=branch)
704
+ runner.collect_and_sync(branch=branch)
705
+
706
+ if self.status != DataSourceStatusChoices.FAILED:
707
+ self.status = DataSourceStatusChoices.COMPLETED
715
708
 
716
- self.status = DataSourceStatusChoices.COMPLETED
717
709
  except Exception as e:
718
710
  self.status = DataSourceStatusChoices.FAILED
719
711
  self.logger.log_failure(f"Branch Failed: `{e}`", obj=branch)
@@ -809,6 +801,8 @@ class IPFabricBranch(JobsMixin, Branch):
809
801
  statistics = {}
810
802
  if job_results:
811
803
  for model, stats in job_results["statistics"].items():
804
+ if not stats["total"]:
805
+ continue
812
806
  if stats["total"] > 0:
813
807
  statistics[model] = stats["current"] / stats["total"] * 100
814
808
  else:
@@ -820,7 +814,15 @@ class IPFabricBranch(JobsMixin, Branch):
820
814
  with transaction.atomic():
821
815
  for change in self.staged_changes.all():
822
816
  logger.debug("Applying change: %s", change)
823
- change.apply()
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
824
826
  signals.pre_delete.disconnect(handle_deleted_object)
825
827
  self.staged_changes.all().delete()
826
828
  signals.pre_delete.connect(handle_deleted_object, sender=StagedChange)
@@ -840,6 +842,7 @@ class IPFabricBranch(JobsMixin, Branch):
840
842
  # Begin Sync
841
843
  logger.debug(f"Merging {self.name}")
842
844
  try:
845
+ signals.pre_save.connect(clear_other_primary_ip, sender=Device)
843
846
  signals.post_save.disconnect(
844
847
  assign_virtualchassis_master, sender=VirtualChassis
845
848
  )
@@ -847,6 +850,7 @@ class IPFabricBranch(JobsMixin, Branch):
847
850
  signals.post_save.connect(
848
851
  assign_virtualchassis_master, sender=VirtualChassis
849
852
  )
853
+ signals.pre_save.disconnect(clear_other_primary_ip, sender=Device)
850
854
  ipfabricsync.status = DataSourceStatusChoices.COMPLETED
851
855
  except Exception as e:
852
856
  ipfabricsync.status = DataSourceStatusChoices.FAILED
@@ -1,68 +1,29 @@
1
- from typing import Optional
1
+ import logging
2
2
 
3
- from core.models import ObjectType
4
3
  from dcim.models import Device
5
- from dcim.models import Site
6
- from extras.choices import CustomFieldTypeChoices
7
- from extras.choices import CustomFieldUIEditableChoices
8
- from extras.choices import CustomFieldUIVisibleChoices
9
- from extras.choices import CustomLinkButtonClassChoices
10
- from extras.models import CustomField
11
- from extras.models import CustomLink
12
-
13
- from .models import IPFabricBranch
14
- from .models import IPFabricSource
15
-
16
-
17
- def create_custom_field(
18
- field_name: str,
19
- label: str,
20
- models: list,
21
- object_type=None,
22
- cf_type: Optional[str] = "type_text",
23
- ui_visibility: Optional[str] = "VISIBILITY_READ_ONLY",
24
- ):
25
- defaults = {
26
- "label": label,
27
- "related_object_type": ObjectType.objects.get_for_model(object_type)
28
- if object_type
29
- else None,
30
- "ui_visible": getattr(CustomFieldUIVisibleChoices, "ALWAYS"),
31
- "ui_editable": getattr(CustomFieldUIEditableChoices, "NO"),
32
- }
33
-
34
- custom_field, _ = CustomField.objects.update_or_create(
35
- type=getattr(CustomFieldTypeChoices, cf_type.upper()),
36
- name=field_name,
37
- defaults=defaults,
38
- )
39
-
40
- for model in models:
41
- custom_field.object_types.add(ObjectType.objects.get_for_model(model))
42
-
43
-
44
- def ipfabric_netbox_init():
45
- create_custom_field(
46
- "ipfabric_source",
47
- "IP Fabric Source",
48
- [Device, Site],
49
- cf_type="type_object",
50
- object_type=IPFabricSource,
51
- )
52
- create_custom_field(
53
- "ipfabric_branch",
54
- "IP Fabric Last Sync",
55
- [Device, Site],
56
- cf_type="type_object",
57
- object_type=IPFabricBranch,
58
- )
59
- cl, _ = CustomLink.objects.update_or_create(
60
- defaults={
61
- "link_text": "{% if object.custom_field_data.ipfabric_source is defined %}{% set SOURCE_ID = object.custom_field_data.ipfabric_source %}{% if SOURCE_ID %}IP Fabric{% endif %}{% endif %}",
62
- "link_url": '{% if object.custom_field_data.ipfabric_source is defined %}{% set SOURCE_ID = object.custom_field_data.ipfabric_source %}{% if SOURCE_ID %}{% set BASE_URL = object.custom_fields.filter(related_object_type__model="ipfabricsource").first().related_object_type.model_class().objects.get(pk=SOURCE_ID).url %}{{ BASE_URL }}/inventory/devices?options={"filters":{"sn": ["like","{{ object.serial }}"]}}{% endif %}{%endif%}',
63
- "new_window": True,
64
- "button_class": CustomLinkButtonClassChoices.BLUE,
65
- },
66
- name="ipfabric",
67
- )
68
- cl.object_types.add(ObjectType.objects.get_for_model(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
ipfabric_netbox/tables.py CHANGED
@@ -143,6 +143,8 @@ class SyncTable(NetBoxTable):
143
143
 
144
144
 
145
145
  class StagedChangesTable(NetBoxTable):
146
+ # There is no view for single StagedChange, remove the link in ID
147
+ id = tables.Column(verbose_name=_("ID"))
146
148
  pk = None
147
149
  object_type = tables.Column(
148
150
  accessor="object_type.model", verbose_name="Object Type"
@@ -172,6 +174,8 @@ class StagedChangesTable(NetBoxTable):
172
174
  name = f"Tagging object ({record.data['object_id']})"
173
175
  elif value == "prefix":
174
176
  name = f"{record.data['prefix']} ({record.data['vrf']})"
177
+ elif value == "MAC address":
178
+ name = record.data["mac_address"]
175
179
  else:
176
180
  name = record.data
177
181
  else: