ipfabric_netbox 4.1.0b3__py3-none-any.whl → 4.1.0b5__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.
@@ -6,7 +6,7 @@ class NetboxIPFabricConfig(PluginConfig):
6
6
  name = "ipfabric_netbox"
7
7
  verbose_name = "NetBox IP Fabric SoT Plugin"
8
8
  description = "Sync IP Fabric into NetBox"
9
- version = "4.1.0b3"
9
+ version = "4.1.0b5"
10
10
  base_url = "ipfabric"
11
11
  min_version = "4.2.4"
12
12
 
@@ -10,7 +10,6 @@ from ipfabric_netbox.models import IPFabricSource
10
10
  from ipfabric_netbox.models import IPFabricSync
11
11
  from ipfabric_netbox.models import IPFabricTransformMap
12
12
 
13
- # from .serializers import IPFabricSyncSerializer
14
13
 
15
14
  __all__ = (
16
15
  "NestedIPFabricSourceSerializer",
@@ -7,8 +7,10 @@ from rest_framework import serializers
7
7
 
8
8
  from .nested_serializers import NestedIPFabricSnapshotSerializer
9
9
  from .nested_serializers import NestedIPFabricSourceSerializer
10
+ from .nested_serializers import NestedIPFabricSyncSerializer
10
11
  from .nested_serializers import NestedIPFabricTransformMapSerializer
11
12
  from ipfabric_netbox.models import IPFabricIngestion
13
+ from ipfabric_netbox.models import IPFabricIngestionIssue
12
14
  from ipfabric_netbox.models import IPFabricRelationshipField
13
15
  from ipfabric_netbox.models import IPFabricSnapshot
14
16
  from ipfabric_netbox.models import IPFabricSource
@@ -23,7 +25,9 @@ __all__ = (
23
25
  "IPFabricRelationshipFieldSerializer",
24
26
  "IPFabricTransformFieldSerializer",
25
27
  "IPFabricTransformMapSerializer",
28
+ "IPFabricTransformMapGroupSerializer",
26
29
  "IPFabricIngestionSerializer",
30
+ "IPFabricIngestionIssueSerializer",
27
31
  "IPFabricSourceSerializer",
28
32
  )
29
33
 
@@ -133,7 +137,7 @@ class IPFabricTransformFieldSerializer(NetBoxModelSerializer):
133
137
 
134
138
  class IPFabricIngestionSerializer(NetBoxModelSerializer):
135
139
  branch = BranchSerializer(read_only=True)
136
- sync = IPFabricSyncSerializer(read_only=True)
140
+ sync = NestedIPFabricSyncSerializer(read_only=True)
137
141
 
138
142
  class Meta:
139
143
  model = IPFabricIngestion
@@ -145,6 +149,24 @@ class IPFabricIngestionSerializer(NetBoxModelSerializer):
145
149
  ]
146
150
 
147
151
 
152
+ class IPFabricIngestionIssueSerializer(NetBoxModelSerializer):
153
+ ingestion = IPFabricIngestionSerializer(read_only=True)
154
+
155
+ class Meta:
156
+ model = IPFabricIngestionIssue
157
+ fields = [
158
+ "id",
159
+ "ingestion",
160
+ "timestamp",
161
+ "model",
162
+ "message",
163
+ "raw_data",
164
+ "coalesce_fields",
165
+ "defaults",
166
+ "exception",
167
+ ]
168
+
169
+
148
170
  class IPFabricSourceSerializer(NetBoxModelSerializer):
149
171
  status = ChoiceField(choices=DataSourceStatusChoices)
150
172
  url = serializers.URLField()
@@ -1,6 +1,7 @@
1
1
  # api/urls.py
2
2
  from netbox.api.routers import NetBoxRouter
3
3
 
4
+ from ipfabric_netbox.api.views import IPFabricIngestionIssueViewSet
4
5
  from ipfabric_netbox.api.views import IPFabricIngestionViewSet
5
6
  from ipfabric_netbox.api.views import IPFabricRelationshipFieldiewSet
6
7
  from ipfabric_netbox.api.views import IPFabricSnapshotViewSet
@@ -18,6 +19,7 @@ router.register("transform-map-group", IPFabricTransformMapGroupViewSet)
18
19
  router.register("transform-map", IPFabricTransformMapViewSet)
19
20
  router.register("sync", IPFabricSyncViewSet)
20
21
  router.register("ingestion", IPFabricIngestionViewSet)
22
+ router.register("ingestion-issues", IPFabricIngestionIssueViewSet)
21
23
  router.register("transform-field", IPFabricTransformFieldiewSet)
22
24
  router.register("relationship-field", IPFabricRelationshipFieldiewSet)
23
25
  urlpatterns = router.urls
@@ -5,6 +5,7 @@ from rest_framework.decorators import action
5
5
  from rest_framework.response import Response
6
6
  from utilities.query import count_related
7
7
 
8
+ from .serializers import IPFabricIngestionIssueSerializer
8
9
  from .serializers import IPFabricIngestionSerializer
9
10
  from .serializers import IPFabricRelationshipFieldSerializer
10
11
  from .serializers import IPFabricSnapshotSerializer
@@ -18,6 +19,7 @@ from ipfabric_netbox.filtersets import IPFabricSourceFilterSet
18
19
  from ipfabric_netbox.filtersets import IPFabricTransformFieldFilterSet
19
20
  from ipfabric_netbox.models import IPFabricData
20
21
  from ipfabric_netbox.models import IPFabricIngestion
22
+ from ipfabric_netbox.models import IPFabricIngestionIssue
21
23
  from ipfabric_netbox.models import IPFabricRelationshipField
22
24
  from ipfabric_netbox.models import IPFabricSnapshot
23
25
  from ipfabric_netbox.models import IPFabricSource
@@ -59,6 +61,11 @@ class IPFabricIngestionViewSet(NetBoxReadOnlyModelViewSet):
59
61
  serializer_class = IPFabricIngestionSerializer
60
62
 
61
63
 
64
+ class IPFabricIngestionIssueViewSet(NetBoxReadOnlyModelViewSet):
65
+ queryset = IPFabricIngestionIssue.objects.all()
66
+ serializer_class = IPFabricIngestionIssueSerializer
67
+
68
+
62
69
  class IPFabricSnapshotViewSet(NetBoxModelViewSet):
63
70
  queryset = IPFabricSnapshot.objects.all()
64
71
  serializer_class = IPFabricSnapshotSerializer
@@ -1,24 +1,81 @@
1
+ from typing import TYPE_CHECKING
2
+
1
3
  from core.exceptions import SyncError
2
4
 
5
+ if TYPE_CHECKING:
6
+ from .models import IPFabricIngestionIssue
7
+ from .models import IPFabricIngestion
8
+
9
+
10
+ class IngestionIssue(Exception):
11
+ """
12
+ This exception is used to indicate an issue during the ingestion process.
13
+ """
3
14
 
4
- class ErrorMixin(Exception):
15
+ # Store created issue object ID if it exists for this exception
16
+ issue_id = None
5
17
  model: str = ""
6
18
  defaults: dict[str, str] = {}
7
19
  coalesce_fields: dict[str, str] = {}
8
20
 
9
- def __init__(self, model: str, context: dict, data: dict = None):
21
+ def __init__(self, model: str, data: dict, context: dict = None, issue_id=None):
10
22
  super().__init__()
11
23
  self.model = model
12
- self.data = data or {}
24
+ self.data = data
25
+ context = context or {}
13
26
  self.defaults = context.pop("defaults", {})
14
27
  self.coalesce_fields = context
28
+ self.issue_id = issue_id
15
29
 
16
30
 
17
- class SearchError(ErrorMixin, LookupError):
31
+ class SearchError(IngestionIssue, LookupError):
32
+ def __init__(self, message: str, *args, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.message = message
35
+
18
36
  def __str__(self):
19
- return f"{self.model} with these keys not found: {self.coalesce_fields}."
37
+ return self.message
20
38
 
21
39
 
22
- class SyncDataError(ErrorMixin, SyncError):
40
+ class SyncDataError(IngestionIssue, SyncError):
23
41
  def __str__(self):
24
42
  return f"Sync failed for {self.model}: coalesce_fields={self.coalesce_fields} defaults={self.defaults}."
43
+
44
+
45
+ class IPAddressDuplicateError(IngestionIssue, SyncError):
46
+ def __str__(self):
47
+ return f"IP address {self.data.get('address')} already exists in {self.model} with coalesce_fields={self.coalesce_fields}."
48
+
49
+
50
+ def create_or_get_sync_issue(
51
+ exception: Exception,
52
+ ingestion: "IPFabricIngestion",
53
+ message: str = None,
54
+ model: str = None,
55
+ context: dict = None,
56
+ data: dict = None,
57
+ ) -> (bool, "IPFabricIngestionIssue"):
58
+ """
59
+ Helper function to handle sync errors and create IPFabricIngestionIssue if needed.
60
+ """
61
+ context = context or {}
62
+
63
+ # TODO: This is to prevent circular import issues, clean it up later.
64
+ from .models import IPFabricIngestionIssue
65
+
66
+ if not hasattr(exception, "issue_id") or not exception.issue_id:
67
+ issue = IPFabricIngestionIssue.objects.create(
68
+ ingestion=ingestion,
69
+ exception=exception.__class__.__name__,
70
+ message=message or getattr(exception, "message", str(exception)),
71
+ model=model,
72
+ coalesce_fields={k: v for k, v in context.items() if k not in ["defaults"]},
73
+ defaults=context.get("defaults", dict()),
74
+ raw_data=data or dict(),
75
+ )
76
+ if hasattr(exception, "issue_id"):
77
+ exception.issue_id = issue.id
78
+ return True, issue
79
+ else:
80
+ issue = IPFabricIngestionIssue.objects.get(id=exception.issue_id)
81
+ return False, issue
@@ -10,6 +10,7 @@ from netbox_branching.models import ChangeDiff
10
10
 
11
11
  from .models import IPFabricData
12
12
  from .models import IPFabricIngestion
13
+ from .models import IPFabricIngestionIssue
13
14
  from .models import IPFabricSnapshot
14
15
  from .models import IPFabricSource
15
16
  from .models import IPFabricSync
@@ -37,6 +38,35 @@ class IPFabricIngestionChangeFilterSet(BaseFilterSet):
37
38
  )
38
39
 
39
40
 
41
+ class IPFabricIngestionIssueFilterSet(BaseFilterSet):
42
+ q = django_filters.CharFilter(method="search")
43
+
44
+ class Meta:
45
+ model = IPFabricIngestionIssue
46
+ fields = [
47
+ "model",
48
+ "timestamp",
49
+ "raw_data",
50
+ "coalesce_fields",
51
+ "defaults",
52
+ "exception",
53
+ "message",
54
+ ]
55
+
56
+ def search(self, queryset, name, value):
57
+ if not value.strip():
58
+ return queryset
59
+ return queryset.filter(
60
+ Q(model__icontains=value)
61
+ | Q(timestamp__icontains=value)
62
+ | Q(raw_data__icontains=value)
63
+ | Q(coalesce_fields__icontains=value)
64
+ | Q(defaults__icontains=value)
65
+ | Q(exception__icontains=value)
66
+ | Q(message__icontains=value)
67
+ )
68
+
69
+
40
70
  class IPFabricDataFilterSet(BaseFilterSet):
41
71
  q = django_filters.CharFilter(method="search")
42
72
 
@@ -0,0 +1,48 @@
1
+ # Generated by Django 5.2 on 2025-07-11 19:39
2
+ import django.db.models.deletion
3
+ import django.utils.timezone
4
+ from django.db import migrations
5
+ from django.db import models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ (
11
+ "ipfabric_netbox",
12
+ "0014_ipfabrictransformmapgroup_ipfabrictransformmap_group",
13
+ ),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name="IPFabricIngestionIssue",
19
+ fields=[
20
+ (
21
+ "id",
22
+ models.BigAutoField(
23
+ auto_created=True, primary_key=True, serialize=False
24
+ ),
25
+ ),
26
+ ("timestamp", models.DateTimeField(default=django.utils.timezone.now)),
27
+ ("model", models.CharField(blank=True, max_length=100, null=True)),
28
+ ("message", models.TextField()),
29
+ ("raw_data", models.TextField(blank=True, default="")),
30
+ ("coalesce_fields", models.TextField(blank=True, default="")),
31
+ ("defaults", models.TextField(blank=True, default="")),
32
+ ("exception", models.TextField()),
33
+ (
34
+ "ingestion",
35
+ models.ForeignKey(
36
+ on_delete=django.db.models.deletion.CASCADE,
37
+ related_name="issues",
38
+ to="ipfabric_netbox.ipfabricingestion",
39
+ ),
40
+ ),
41
+ ],
42
+ options={
43
+ "verbose_name": "IP Fabric Ingestion Issue",
44
+ "verbose_name_plural": "IP Fabric Ingestion Issues",
45
+ "ordering": ["timestamp"],
46
+ },
47
+ ),
48
+ ]
ipfabric_netbox/models.py CHANGED
@@ -236,6 +236,8 @@ class IPFabricTransformMap(NetBoxModel):
236
236
  target_class = self.target_model.model_class()
237
237
  queryset = target_class.objects.using(connection_name)
238
238
 
239
+ # Don't change context since it's used in case of exception for IPFabricIngestionIssue
240
+ context = deepcopy(context)
239
241
  defaults = context.pop("defaults", {})
240
242
 
241
243
  with transaction.atomic(using=connection_name):
@@ -967,6 +969,29 @@ class IPFabricIngestion(JobsMixin, models.Model):
967
969
  )
968
970
 
969
971
 
972
+ class IPFabricIngestionIssue(models.Model):
973
+ objects = RestrictedQuerySet.as_manager()
974
+
975
+ ingestion = models.ForeignKey(
976
+ to="IPFabricIngestion", on_delete=models.CASCADE, related_name="issues"
977
+ )
978
+ timestamp = models.DateTimeField(default=timezone.now)
979
+ model = models.CharField(max_length=100, blank=True, null=True)
980
+ message = models.TextField()
981
+ raw_data = models.TextField(blank=True, default="")
982
+ coalesce_fields = models.TextField(blank=True, default="")
983
+ defaults = models.TextField(blank=True, default="")
984
+ exception = models.TextField()
985
+
986
+ class Meta:
987
+ ordering = ["timestamp"]
988
+ verbose_name = "IP Fabric Ingestion Issue"
989
+ verbose_name_plural = "IP Fabric Ingestion Issues"
990
+
991
+ def __str__(self):
992
+ return f"[{self.timestamp}] {self.message}"
993
+
994
+
970
995
  class IPFabricData(models.Model):
971
996
  snapshot_data = models.ForeignKey(
972
997
  to=IPFabricSnapshot,
ipfabric_netbox/tables.py CHANGED
@@ -8,6 +8,7 @@ from netbox_branching.models import ChangeDiff
8
8
 
9
9
  from .models import IPFabricData
10
10
  from .models import IPFabricIngestion
11
+ from .models import IPFabricIngestionIssue
11
12
  from .models import IPFabricRelationshipField
12
13
  from .models import IPFabricSnapshot
13
14
  from .models import IPFabricSource
@@ -199,6 +200,28 @@ class IPFabricIngestionChangesTable(NetBoxTable):
199
200
  default_columns = ("object", "action", "object_type", "actions")
200
201
 
201
202
 
203
+ class IPFabricIngestionIssuesTable(NetBoxTable):
204
+ id = tables.Column(verbose_name=_("ID"))
205
+ exception = tables.Column(verbose_name="Exception Type")
206
+ message = tables.Column(verbose_name="Error Message")
207
+ actions = None
208
+
209
+ class Meta(NetBoxTable.Meta):
210
+ model = IPFabricIngestionIssue
211
+ fields = (
212
+ "model",
213
+ "timestamp",
214
+ "raw_data",
215
+ "coalesce_fields",
216
+ "defaults",
217
+ "exception",
218
+ "message",
219
+ )
220
+ default_columns = ("model", "exception", "message")
221
+ empty_text = _("No Ingestion Issues found")
222
+ order_by = "id"
223
+
224
+
202
225
  class DeviceIPFTable(tables.Table):
203
226
  hostname = Column()
204
227
 
@@ -36,6 +36,11 @@
36
36
  </div>
37
37
  {% endblock control-buttons %}
38
38
 
39
+ {% block tabs %}
40
+ <div id="object_tabs" hx-swap-oob="true">
41
+ {% include 'ipfabric_netbox/partials/object_tabs.html' with object=object %}
42
+ </div>
43
+ {% endblock tabs %}
39
44
 
40
45
  {% block content %}
41
46
  <div class="row mb-3">
@@ -70,7 +75,6 @@
70
75
  <th scope="row">Ingestion Status</th>
71
76
  <td><div id="ingestion_status" hx-swap-oob="true">{% include 'ipfabric_netbox/partials/ingestion_status.html' with object=object %}</div></td>
72
77
  <!-- <td >{% badge object.sync.get_status_display bg_color=object.sync.get_status_color %}</td> -->
73
- </div>
74
78
  </tr>
75
79
  <tr>
76
80
  <th scope="row">Snapshot</th>
@@ -88,59 +92,26 @@
88
92
  {% plugin_left_page object %}
89
93
  </div>
90
94
  <div class="col col-md-6">
91
- <div class="card">
92
- <h5 class="card-header">Statistics</h5>
93
- <div class="card-body">
94
- <table class="table table-hover attr-table">
95
- <tr>
96
- <th scope="row">Created</th>
97
- <td>
98
- {% if object.num_created %}
99
- {{ object.num_created }}
100
- {% else %}
101
- {{ ''|placeholder }}
102
- {% endif %}
103
- </td>
104
- </tr>
105
- <tr>
106
- <th scope="row">Updated</th>
107
- <td>
108
- {% if object.num_updated %}
109
- {{ object.num_updated }}
110
- {% else %}
111
- {{ ''|placeholder }}
112
- {% endif %}
113
- </td>
114
- </tr>
115
- <tr>
116
- <th scope="row">Deleted</th>
117
- <td>
118
- {% if object.num_deleted %}
119
- {{ object.num_deleted }}
120
- {% else %}
121
- {{ ''|placeholder }}
122
- {% endif %}
123
- </td>
124
- </tr>
125
- </table>
126
- </div>
95
+ <div id="ingestion_statistics" hx-swap-oob="true">
96
+ {% include 'ipfabric_netbox/partials/ingestion_statistics.html' with object=object %}
127
97
  </div>
128
- <div id="ingestion_progress">
98
+ <div id="ingestion_progress" hx-swap-oob="true">
129
99
  {% include 'ipfabric_netbox/partials/ingestion_progress.html' %}
130
100
  </div>
131
101
  <!-- {% include 'inc/panels/related_objects.html' %} -->
132
102
  {% include 'inc/panels/custom_fields.html' %}
133
103
  {% plugin_right_page object %}
134
104
  </div>
135
- </div>
136
- <div class="row mb-3">
137
- <div class="col col-md-12" {% if not object.job.completed %} hx-get="{% url 'plugins:ipfabric_netbox:ipfabricingestion_logs' pk=object.pk %}?type=info"
138
- hx-trigger="every 5s" hx-target="#ingestion_logs" hx-select="#ingestion_logs" hx-select-oob="#ingestion_status:innerHTML"
139
- hx-swap="innerHTML" {% endif %}>
140
- <div id="ingestion_logs">{% include 'ipfabric_netbox/partials/job_logs.html' with job=object.job %}</div>
105
+ <div class="row mb-3">
106
+ <div class="col col-md-12" {% if not object.job.completed %} hx-get="{% url 'plugins:ipfabric_netbox:ipfabricingestion_logs' pk=object.pk %}?type=info"
107
+ hx-trigger="every 5s" hx-target="#ingestion_logs" hx-select="#ingestion_logs" hx-select-oob="#ingestion_status:innerHTML,#ingestion_progress:innerHTML,#ingestion_statistics:innerHTML,#object_tabs:innerHTML"
108
+ hx-swap="innerHTML" {% endif %}>
109
+ <div id="ingestion_logs">{% include 'ipfabric_netbox/partials/job_logs.html' with job=object.job %}</div>
110
+ </div>
141
111
  </div>
112
+ </div>
142
113
  {% endblock content %}
143
114
 
144
- {% block modals %}
145
- {% include 'inc/htmx_modal.html' with size='lg' %}
146
- {% endblock modals %}
115
+ {% block modals %}
116
+ {% include 'inc/htmx_modal.html' with size='lg' %}
117
+ {% endblock modals %}
@@ -1,4 +1,9 @@
1
-
1
+ <div id="object_tabs" hx-swap-oob="true">
2
+ {% include 'ipfabric_netbox/partials/object_tabs.html' with object=object %}
3
+ </div>
4
+ <div id="ingestion_statistics" hx-swap-oob="true">
5
+ {% include 'ipfabric_netbox/partials/ingestion_statistics.html' with object=object %}
6
+ </div>
2
7
  <div id="ingestion_status" hx-swap-oob="true">
3
8
  {% include 'ipfabric_netbox/partials/ingestion_status.html' with object=object %}
4
9
  </div>
@@ -0,0 +1,37 @@
1
+ <div class="card">
2
+ <h5 class="card-header">Statistics</h5>
3
+ <div class="card-body">
4
+ <table class="table table-hover attr-table">
5
+ <tr>
6
+ <th scope="row">Created</th>
7
+ <td>
8
+ {% if object.num_created %}
9
+ {{ object.num_created }}
10
+ {% else %}
11
+ {{ ''|placeholder }}
12
+ {% endif %}
13
+ </td>
14
+ </tr>
15
+ <tr>
16
+ <th scope="row">Updated</th>
17
+ <td>
18
+ {% if object.num_updated %}
19
+ {{ object.num_updated }}
20
+ {% else %}
21
+ {{ ''|placeholder }}
22
+ {% endif %}
23
+ </td>
24
+ </tr>
25
+ <tr>
26
+ <th scope="row">Deleted</th>
27
+ <td>
28
+ {% if object.num_deleted %}
29
+ {{ object.num_deleted }}
30
+ {% else %}
31
+ {{ ''|placeholder }}
32
+ {% endif %}
33
+ </td>
34
+ </tr>
35
+ </table>
36
+ </div>
37
+ </div>
@@ -1 +1 @@
1
- {% badge object.sync.get_status_display bg_color=object.sync.get_status_color %}
1
+ {% badge object.sync.get_status_display bg_color=object.sync.get_status_color %} {% if object.errors and object.errors.count %}(with {{ object.errors.count }} issues){% endif %}
@@ -0,0 +1,12 @@
1
+ {% load helpers %}
2
+ {% load tabs %}
3
+
4
+ <ul class="nav nav-tabs" role="presentation">
5
+ {# Primary tab #}
6
+ <li class="nav-item">
7
+ <a class="nav-link{% if not tab %} active{% endif %}" href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
8
+ </li>
9
+
10
+ {# Include tabs for registered model views #}
11
+ {% model_view_tabs object %}
12
+ </ul>
@@ -3,6 +3,7 @@ import logging
3
3
  from importlib import metadata
4
4
  from typing import Callable
5
5
  from typing import TYPE_CHECKING
6
+ from typing import TypeVar
6
7
 
7
8
  from core.choices import DataSourceStatusChoices
8
9
  from core.exceptions import SyncError
@@ -21,24 +22,22 @@ from netbox.config import get_config
21
22
  from netutils.utils import jinja2_convenience_function
22
23
 
23
24
  from ..choices import IPFabricSourceTypeChoices
25
+ from ..exceptions import create_or_get_sync_issue
26
+ from ..exceptions import IPAddressDuplicateError
24
27
  from ..exceptions import SearchError
25
28
  from ..exceptions import SyncDataError
26
29
  from .nbutils import device_serial_max_length
27
30
  from .nbutils import order_devices
28
31
  from .nbutils import order_members
29
32
 
30
- FAILED_CREATE_INSTANCE_TEMPLATE = (
31
- "Failed to create instance of '{model}': <br/>"
32
- "message: '{err}'<br/>"
33
- "raw data: '{data}'<br/>"
34
- "context: '{context}'<br/>"
35
- )
36
-
37
33
  if TYPE_CHECKING:
38
34
  from ..models import IPFabricIngestion
35
+ from ipam.models import IPAddress
39
36
 
40
37
  logger = logging.getLogger("ipfabric_netbox.utilities.ipf_utils")
41
38
 
39
+ ModelTypeVar = TypeVar("ModelTypeVar", bound=Model)
40
+
42
41
 
43
42
  def slugify_text(value):
44
43
  return slugify(value)
@@ -98,7 +97,7 @@ class IPFabric(object):
98
97
  formatted_snapshots[snapshot_ref] = (description, snapshot.snapshot_id)
99
98
  return formatted_snapshots
100
99
 
101
- def get_sites(self, snapshot=None) -> dict():
100
+ def get_sites(self, snapshot=None) -> list:
102
101
  if snapshot:
103
102
  raw_sites = self.ipf.inventory.sites.all(snapshot_id=snapshot)
104
103
  else:
@@ -159,6 +158,9 @@ class IPFabricSyncRunner(object):
159
158
  except Exception as err:
160
159
  # Log the error to logger outside of job - console/file
161
160
  logger.error(err, exc_info=True)
161
+ if hasattr(err, "issue_id") and err.issue_id:
162
+ # The error is already logged to user, no need to log it again
163
+ return None
162
164
  # Logging section for logs inside job - facing user
163
165
  self = args[0]
164
166
  if isinstance(err, SearchError):
@@ -172,6 +174,11 @@ class IPFabricSyncRunner(object):
172
174
  f"Syncing `{err.model}` is disabled in settings, but hit above error trying to find the correct item. Please check your transform maps and/or existing data.",
173
175
  obj=self.sync,
174
176
  )
177
+ if isinstance(err, IPAddressDuplicateError):
178
+ self.logger.log_warning(
179
+ f"IP Address `{err.data.get('address')}` already exists in `{err.model}` with coalesce fields: `{err.coalesce_fields}`. Please check your transform maps and/or existing data.",
180
+ obj=self.sync,
181
+ )
175
182
  else:
176
183
  self.logger.log_failure(
177
184
  f"Syncing failed with: `{err}`. See above error for more details.",
@@ -203,13 +210,21 @@ class IPFabricSyncRunner(object):
203
210
  except Exception as err:
204
211
  message = f"Error getting context for `{model}`."
205
212
  if isinstance(err, ObjectDoesNotExist):
206
- message += " Could not find related object using on template in transform maps."
213
+ message += (
214
+ " Could not find related object using template in transform maps."
215
+ )
207
216
  elif isinstance(err, MultipleObjectsReturned):
208
217
  message += " Multiple objects returned using on template in transform maps, the template is not strict enough."
209
- self.logger.log_failure(
210
- f"<b>{message}</b><br/>data: `{data}`<br/>error: `{err}`", obj=self.sync
218
+ _, issue = create_or_get_sync_issue(
219
+ exception=err,
220
+ ingestion=self.ingestion,
221
+ message=message,
222
+ model=model,
223
+ data=data,
211
224
  )
212
- raise SyncError(err) from err
225
+ raise SearchError(
226
+ message=message, data=data, model=model, issue_id=issue.id
227
+ ) from err
213
228
 
214
229
  queryset = transform_map.target_model.model_class().objects
215
230
 
@@ -228,25 +243,50 @@ class IPFabricSyncRunner(object):
228
243
  context.pop("defaults", None)
229
244
  object = queryset.using(connection_name).get(**context)
230
245
  except queryset.model.DoesNotExist as err:
231
- self.logger.log_failure(
232
- f"<b>`{model}` with these keys not found: `{context}`.</b><br/>Original data: `{data}`.",
233
- obj=self.sync,
246
+ message = f"Instance of `{model}` not found."
247
+ _, issue = create_or_get_sync_issue(
248
+ exception=err,
249
+ ingestion=self.ingestion,
250
+ message=message,
251
+ model=model,
252
+ context=context,
253
+ data=data,
234
254
  )
235
- raise SearchError(model=model, context=context, data=data) from err
255
+ raise SearchError(
256
+ message=message,
257
+ model=model,
258
+ context=context,
259
+ data=data,
260
+ issue_id=issue.id,
261
+ ) from err
236
262
  except queryset.model.MultipleObjectsReturned as err:
237
- self.logger.log_failure(
238
- f"<b>Multiple `{model}` with these keys found: `{context}`.</b><br/>Original data: `{data}`.",
239
- obj=self.sync,
263
+ message = f"Multiple instances of `{model}` found."
264
+ _, issue = create_or_get_sync_issue(
265
+ exception=err,
266
+ ingestion=self.ingestion,
267
+ message=message,
268
+ model=model,
269
+ context=context,
270
+ data=data,
240
271
  )
241
- raise SearchError(model=model, context=context, data=data) from err
272
+ raise SearchError(
273
+ message=message,
274
+ model=model,
275
+ context=context,
276
+ data=data,
277
+ issue_id=issue.id,
278
+ ) from err
242
279
  except Exception as err:
243
- self.logger.log_failure(
244
- FAILED_CREATE_INSTANCE_TEMPLATE.format(
245
- model=model, err=err, data=data, context=context
246
- ),
247
- obj=self.sync,
280
+ _, issue = create_or_get_sync_issue(
281
+ exception=err,
282
+ ingestion=self.ingestion,
283
+ model=model,
284
+ context=context,
285
+ data=data,
248
286
  )
249
- raise SyncDataError(model=model, context=context, data=data) from err
287
+ raise SyncDataError(
288
+ model=model, context=context, data=data, issue_id=issue.id
289
+ ) from err
250
290
 
251
291
  return object
252
292
 
@@ -436,7 +476,6 @@ class IPFabricSyncRunner(object):
436
476
  f"{len(data.get('ipaddress', []))} management IP's collected",
437
477
  obj=self.sync.snapshot_data.source,
438
478
  )
439
-
440
479
  self.logger.log_info("Ordering devices", obj=self.sync)
441
480
 
442
481
  members = order_members(data.get("virtualchassis", []))
@@ -526,11 +565,29 @@ class IPFabricSyncRunner(object):
526
565
  )
527
566
 
528
567
  @handle_errors
529
- def sync_model(self, app_label: str, model: str, data: dict | None) -> Model | None:
568
+ def sync_model(
569
+ self,
570
+ app_label: str,
571
+ model: str,
572
+ data: dict | None,
573
+ stats: bool = True,
574
+ sync: bool = False,
575
+ ) -> ModelTypeVar | None:
530
576
  """Sync a single item to NetBox."""
577
+ # The `sync` param is a workaround since we need to get some models (Device...) even when not syncing them.
578
+ if not sync:
579
+ return None
580
+
531
581
  if not data:
532
582
  return None
533
- return self.get_model_or_update(app_label, model, data)
583
+
584
+ instance = self.get_model_or_update(app_label, model, data)
585
+
586
+ # Only log when we successfully synced the item and asked for it
587
+ if stats:
588
+ self.logger.increment_statistics(model=model)
589
+
590
+ return instance
534
591
 
535
592
  def sync_items(
536
593
  self,
@@ -548,11 +605,14 @@ class IPFabricSyncRunner(object):
548
605
  return
549
606
 
550
607
  for item in items:
551
- synced_object = self.sync_model(app_label=app_label, model=model, data=item)
608
+ synced_object = self.sync_model(
609
+ app_label=app_label,
610
+ model=model,
611
+ data=item,
612
+ sync=self.settings.get(model),
613
+ )
552
614
  if synced_object is None:
553
615
  continue
554
- # Only log when we successfully synced the item
555
- self.logger.increment_statistics(model=model)
556
616
 
557
617
  if cf:
558
618
  synced_object.snapshot()
@@ -588,22 +648,40 @@ class IPFabricSyncRunner(object):
588
648
  devices_total = len(devices)
589
649
 
590
650
  for device in devices:
591
- if self.sync_model("dcim", "manufacturer", device) is None:
592
- continue
593
- if self.sync_model("dcim", "devicetype", device) is None:
594
- continue
595
- if self.sync_model("dcim", "platform", device) is None:
596
- continue
597
- if self.sync_model("dcim", "devicerole", device) is None:
598
- continue
651
+ self.sync_model(
652
+ "dcim", "manufacturer", device, sync=self.settings.get("manufacturer")
653
+ )
654
+ self.sync_model(
655
+ "dcim", "devicetype", device, sync=self.settings.get("devicetype")
656
+ )
657
+ self.sync_model(
658
+ "dcim", "platform", device, sync=self.settings.get("platform")
659
+ )
660
+ self.sync_model(
661
+ "dcim", "devicerole", device, sync=self.settings.get("devicerole")
662
+ )
599
663
 
600
664
  virtual_chassis = device.get("virtual_chassis", {})
601
- self.sync_model("dcim", "virtualchassis", virtual_chassis)
665
+ self.sync_model(
666
+ "dcim",
667
+ "virtualchassis",
668
+ virtual_chassis,
669
+ False,
670
+ sync=self.settings.get("virtualchassis"),
671
+ )
602
672
 
603
- if (device_object := self.sync_model("dcim", "device", device)) is None:
604
- continue
673
+ # We need to get a Device instance even when not syncing it but syncing Interfaces, IPs or MACs
674
+ device_object: Device | None = self.sync_model(
675
+ "dcim",
676
+ "device",
677
+ device,
678
+ sync=self.settings.get("device")
679
+ or self.settings.get("interface")
680
+ or self.settings.get("ipaddress")
681
+ or self.settings.get("macaddress"),
682
+ )
605
683
 
606
- if self.settings.get("device"):
684
+ if device_object and self.settings.get("device"):
607
685
  device_object.snapshot()
608
686
  device_object.custom_field_data[
609
687
  "ipfabric_source"
@@ -612,23 +690,26 @@ class IPFabricSyncRunner(object):
612
690
  device_object.custom_field_data["ipfabric_ingestion"] = ingestion.pk
613
691
  device_object.save()
614
692
 
615
- self.logger.increment_statistics(model="device")
616
- logger.info(
617
- f"Device {self.logger.log_data.get('statistics', {}).get('device', {}).get('current')} out of {devices_total}"
618
- )
693
+ self.logger.increment_statistics(model="device")
694
+ logger.info(
695
+ f"Device {self.logger.log_data.get('statistics', {}).get('device', {}).get('current')} out of {devices_total}"
696
+ )
619
697
 
620
- # The Device exists now, so we can update master of the VC.
621
- # The logic is handled in transform maps.
622
- self.sync_model("dcim", "virtualchassis", virtual_chassis)
698
+ # The Device exists now, so we can update the master of the VC.
699
+ # The logic is handled in transform maps.
700
+ self.sync_model(
701
+ "dcim",
702
+ "virtualchassis",
703
+ virtual_chassis,
704
+ False,
705
+ sync=self.settings.get("virtualchassis"),
706
+ )
623
707
 
624
708
  device_interfaces = interface_dict.get(device.get("sn"), [])
625
709
  for device_interface in device_interfaces:
626
- interface_object = self.sync_interface(
710
+ self.sync_interface(
627
711
  device_interface, managed_ips, device_object, device
628
712
  )
629
- if interface_object is None:
630
- continue
631
- self.logger.increment_statistics(model="interface")
632
713
 
633
714
  @handle_errors
634
715
  def sync_ipaddress(
@@ -638,22 +719,20 @@ class IPFabricSyncRunner(object):
638
719
  primary_ip: str | None,
639
720
  login_ip: str | None,
640
721
  ):
641
- if not self.settings.get("ipaddress") or not managed_ip:
642
- return
643
- ip_address_obj = self.get_model_or_update(
722
+ ip_address_obj: "IPAddress | None" = self.sync_model(
644
723
  "ipam",
645
724
  "ipaddress",
646
725
  managed_ip,
726
+ sync=self.settings.get("ipaddress"),
647
727
  )
648
728
  if ip_address_obj is None:
649
- return
650
- self.logger.increment_statistics(model="ipaddress")
729
+ return None
651
730
 
652
731
  connection_name = self.get_db_connection_name()
653
732
 
654
733
  try:
655
- # Removing other IP is done in .signals.clear_other_primary_ip
656
- # But do it here too so the change is shown in StagedChange diff
734
+ # Removing another IP is done in .signals.clear_other_primary_ip
735
+ # But do it here too, so the change is shown in StagedChange diff
657
736
  other_device = Device.objects.using(connection_name).get(
658
737
  primary_ip4=ip_address_obj
659
738
  )
@@ -669,7 +748,7 @@ class IPFabricSyncRunner(object):
669
748
  device_object.snapshot()
670
749
  device_object.primary_ip4 = ip_address_obj
671
750
  device_object.save(using=connection_name)
672
- except ValueError as err:
751
+ except (ValueError, AttributeError) as err:
673
752
  self.logger.log_failure(
674
753
  f"Error assigning primary IP to device: {err}", obj=self.sync
675
754
  )
@@ -680,17 +759,17 @@ class IPFabricSyncRunner(object):
680
759
  def sync_macaddress(
681
760
  self, data: dict | None, interface_object: Interface
682
761
  ) -> MACAddress | None:
683
- if not self.settings.get("macaddress") or not data:
684
- return None
685
762
  # Need to create MAC Address object before we can assign it to Interface
686
763
  # TODO: Figure out how to do this using transform maps
687
764
  macaddress_data = {
688
765
  "mac": data,
689
- "id": interface_object.pk,
766
+ "id": getattr(interface_object, "pk", None),
690
767
  }
691
- macaddress_object = self.get_model_or_update(
692
- "dcim", "macaddress", macaddress_data
768
+ macaddress_object: MACAddress | None = self.sync_model(
769
+ "dcim", "macaddress", macaddress_data, sync=self.settings.get("macaddress")
693
770
  )
771
+ if macaddress_object is None:
772
+ return None
694
773
  try:
695
774
  interface_object.snapshot()
696
775
  interface_object.primary_mac_address = macaddress_object
@@ -711,15 +790,19 @@ class IPFabricSyncRunner(object):
711
790
  device: dict,
712
791
  ):
713
792
  device_interface["loginIp"] = device.get("loginIp")
714
- interface_object = self.get_model_or_update(
715
- "dcim", "interface", device_interface
793
+ # We need to get an Interface instance even when not syncing it but syncing IPs or MACs
794
+ interface_object: Interface | None = self.sync_model(
795
+ "dcim",
796
+ "interface",
797
+ device_interface,
798
+ sync=self.settings.get("interface")
799
+ or self.settings.get("ipaddress")
800
+ or self.settings.get("macaddress"),
716
801
  )
717
- if interface_object is None:
718
- return None
719
802
 
720
- for ipaddress in managed_ips.get(device_object.serial, {}).get(
721
- interface_object.name, []
722
- ):
803
+ for ipaddress in managed_ips.get(
804
+ getattr(device_object, "serial", None), {}
805
+ ).get(getattr(interface_object, "name", None), []):
723
806
  self.sync_ipaddress(
724
807
  ipaddress,
725
808
  device_object,
ipfabric_netbox/views.py CHANGED
@@ -34,6 +34,7 @@ from utilities.views import ViewTab
34
34
  from .filtersets import IPFabricDataFilterSet
35
35
  from .filtersets import IPFabricIngestionChangeFilterSet
36
36
  from .filtersets import IPFabricIngestionFilterSet
37
+ from .filtersets import IPFabricIngestionIssueFilterSet
37
38
  from .filtersets import IPFabricSnapshotFilterSet
38
39
  from .filtersets import IPFabricSourceFilterSet
39
40
  from .filtersets import IPFabricTransformMapFilterSet
@@ -52,6 +53,7 @@ from .forms import IPFabricTransformMapForm
52
53
  from .forms import IPFabricTransformMapGroupForm
53
54
  from .models import IPFabricData
54
55
  from .models import IPFabricIngestion
56
+ from .models import IPFabricIngestionIssue
55
57
  from .models import IPFabricRelationshipField
56
58
  from .models import IPFabricSnapshot
57
59
  from .models import IPFabricSource
@@ -62,6 +64,7 @@ from .models import IPFabricTransformMapGroup
62
64
  from .tables import DeviceIPFTable
63
65
  from .tables import IPFabricDataTable
64
66
  from .tables import IPFabricIngestionChangesTable
67
+ from .tables import IPFabricIngestionIssuesTable
65
68
  from .tables import IPFabricIngestionTable
66
69
  from .tables import IPFabricRelationshipFieldTable
67
70
  from .tables import IPFabricSnapshotTable
@@ -657,36 +660,8 @@ class IPFabricIngestionListView(generic.ObjectListView):
657
660
  table = IPFabricIngestionTable
658
661
 
659
662
 
660
- @register_model_view(
661
- IPFabricIngestion,
662
- name="logs",
663
- path="logs",
664
- )
665
- class IPFabricIngestionLogView(LoginRequiredMixin, View):
666
- template_name = "ipfabric_netbox/partials/ingestion_all.html"
667
-
668
- def get(self, request, **kwargs):
669
- ingestion_id = kwargs.get("pk")
670
- if request.htmx:
671
- ingestion = IPFabricIngestion.objects.get(pk=ingestion_id)
672
- data = ingestion.get_statistics()
673
- data["object"] = ingestion
674
- data["job"] = ingestion.jobs.first()
675
- response = render(
676
- request,
677
- self.template_name,
678
- data,
679
- )
680
- if ingestion.job.completed:
681
- response["HX-Refresh"] = "true"
682
- return response
683
- else:
684
- return response
685
-
686
-
687
- @register_model_view(IPFabricIngestion)
688
- class IPFabricIngestionView(generic.ObjectView):
689
- queryset = IPFabricIngestion.objects.annotate(
663
+ def annotate_statistics(queryset):
664
+ return queryset.annotate(
690
665
  num_created=models.Count(
691
666
  "branch__changediff",
692
667
  filter=models.Q(
@@ -713,6 +688,39 @@ class IPFabricIngestionView(generic.ObjectView):
713
688
  staged_changes=models.Count(models.F("branch__changediff")),
714
689
  )
715
690
 
691
+
692
+ @register_model_view(
693
+ IPFabricIngestion,
694
+ name="logs",
695
+ path="logs",
696
+ )
697
+ class IPFabricIngestionLogView(LoginRequiredMixin, View):
698
+ template_name = "ipfabric_netbox/partials/ingestion_all.html"
699
+
700
+ def get(self, request, **kwargs):
701
+ ingestion_id = kwargs.get("pk")
702
+ if request.htmx:
703
+ ingestion = annotate_statistics(IPFabricIngestion.objects).get(
704
+ pk=ingestion_id
705
+ )
706
+ data = ingestion.get_statistics()
707
+ data["object"] = ingestion
708
+ data["job"] = ingestion.jobs.first()
709
+ response = render(
710
+ request,
711
+ self.template_name,
712
+ data,
713
+ )
714
+ if ingestion.job.completed:
715
+ response["HX-Refresh"] = "true"
716
+ return response
717
+ return render(request, self.template_name)
718
+
719
+
720
+ @register_model_view(IPFabricIngestion)
721
+ class IPFabricIngestionView(generic.ObjectView):
722
+ queryset = annotate_statistics(IPFabricIngestion.objects)
723
+
716
724
  def get_extra_context(self, request, instance):
717
725
  data = instance.get_statistics()
718
726
  return data
@@ -844,6 +852,23 @@ class IPFabricIngestionChangesView(generic.ObjectChildrenView):
844
852
  return self.child_model.objects.filter(branch=parent.branch)
845
853
 
846
854
 
855
+ @register_model_view(IPFabricIngestion, "ingestion_issues")
856
+ class IPFabricIngestionIssuesView(generic.ObjectChildrenView):
857
+ queryset = IPFabricIngestion.objects.all()
858
+ child_model = IPFabricIngestionIssue
859
+ table = IPFabricIngestionIssuesTable
860
+ template_name = "generic/object_children.html"
861
+ filterset = IPFabricIngestionIssueFilterSet
862
+ tab = ViewTab(
863
+ label="Ingestion Issues",
864
+ badge=lambda obj: IPFabricIngestionIssue.objects.filter(ingestion=obj).count(),
865
+ permission="ipfabric_netbox.view_ipfabricingestionissue",
866
+ )
867
+
868
+ def get_children(self, request, parent):
869
+ return IPFabricIngestionIssue.objects.filter(ingestion=parent)
870
+
871
+
847
872
  @register_model_view(IPFabricIngestion, "delete")
848
873
  class IPFabricIngestionDeleteView(generic.ObjectDeleteView):
849
874
  queryset = IPFabricIngestion.objects.all()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ipfabric_netbox
3
- Version: 4.1.0b3
3
+ Version: 4.1.0b5
4
4
  Summary: NetBox plugin to sync IP Fabric data into NetBox
5
5
  License: MIT
6
6
  Keywords: netbox,ipfabric,plugin,sync
@@ -1,13 +1,13 @@
1
- ipfabric_netbox/__init__.py,sha256=dNmLAwHDbmPgnzHOHoqLDisMshHihZ0VvOzkvp41n-8,674
1
+ ipfabric_netbox/__init__.py,sha256=GlnjXNerOuwwTdZ-lspaHy84E4hf8GkMQ9jafQrWn70,674
2
2
  ipfabric_netbox/api/__init__.py,sha256=DOkvDAI4BoNgdCiNxfseeExEHyOrK8weG-LvjPRyK8A,101
3
- ipfabric_netbox/api/nested_serializers.py,sha256=F-VpAL6xhNSUipuvR9_37sVw-3IPjr-XZB5CkouwelA,2174
4
- ipfabric_netbox/api/serializers.py,sha256=BOhibC7IZDhaPhTAdQmt_RJtVxmgqjt-MXIo7UcNGZc,4496
5
- ipfabric_netbox/api/urls.py,sha256=KRFE8CtqfeK7RF15KG4DDPUGIcjh_0DyT0D1HZmAAyM,1101
6
- ipfabric_netbox/api/views.py,sha256=UhFMh3ioYht8mbCCOcpwiyItsI_NZWRdzYS0Nno9XKg,4750
3
+ ipfabric_netbox/api/nested_serializers.py,sha256=dG9YdK6wY6dDEvUWsjzsla-J_GnB5szzDjrcZhdWoEg,2124
4
+ ipfabric_netbox/api/serializers.py,sha256=KihGDzoYTM9M9lrU0uUQPFl3ru7t59MuIebBdQe_DhY,5130
5
+ ipfabric_netbox/api/urls.py,sha256=oZuDEJ6C7dpz8c9J3aJGi1CBSAij8Y4ClLa3Lu5AWk0,1236
6
+ ipfabric_netbox/api/views.py,sha256=j-5-t22519p-4iJxx6PwUNaJumdweIHCdUkYKijiTyI,5041
7
7
  ipfabric_netbox/choices.py,sha256=tQ5RHwmuArQ7dCLcE3A_Bnw2ctGHgw67z--AawyyPPg,5261
8
8
  ipfabric_netbox/data/transform_map.json,sha256=4PsucgMHcLW3SPoKEptQCd0gA5tCF4hjrR4bGQFCWy8,21744
9
- ipfabric_netbox/exceptions.py,sha256=Za-RutEX9ZIi3r0Rpor1vDGwsC5foeyrfSjUKS3Cwdk,744
10
- ipfabric_netbox/filtersets.py,sha256=N7Zsy60ZgJA6vY2n1MHfzIP62aOt6jdIvHC7azeYka8,5263
9
+ ipfabric_netbox/exceptions.py,sha256=DT4dpbakvqoROtBR_F0LzvQCMNWpGhufFcUbZTx0OLY,2655
10
+ ipfabric_netbox/filtersets.py,sha256=unVb64gR_m5oMRo1nP4yWrcogMRvfnzVQRmna6-9fKw,6121
11
11
  ipfabric_netbox/forms.py,sha256=07p_vngzsnHG0aev83JqBhb8fSBts0w-MTKu4BuoNLw,44494
12
12
  ipfabric_netbox/jobs.py,sha256=KrTUeCuFUIU7vKCUS3RiBYCBG7g7GzhGagM_qFMGQJ4,3089
13
13
  ipfabric_netbox/migrations/0001_initial.py,sha256=VphxkWL6QzWq2tcrdXlog718xQtiEGizKwS830z_fOs,13824
@@ -25,11 +25,12 @@ ipfabric_netbox/migrations/0011_update_part_number_DCIM_inventory_item_template.
25
25
  ipfabric_netbox/migrations/0012_remove_status_field.py,sha256=UZTY2BOBuV4Y_7zuPRfsCFLo57HZ6DsNRIhFlF_VOTM,387
26
26
  ipfabric_netbox/migrations/0013_switch_to_branching_plugin.py,sha256=JfdTNerjuFyjW3pDm4vueOp2lrDh2xt6lL1Et5i4crg,10754
27
27
  ipfabric_netbox/migrations/0014_ipfabrictransformmapgroup_ipfabrictransformmap_group.py,sha256=RCfgKyqQhTkfSPhf0IukI3fjeAEBUs5pKWIpsLxgzp0,2272
28
+ ipfabric_netbox/migrations/0015_ipfabricingestionissue.py,sha256=AjAkyboa4BSXsN53BqzO1k_U6QHu4rlA2IhzhubocJw,1732
28
29
  ipfabric_netbox/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
- ipfabric_netbox/models.py,sha256=vUqJBrSc5SyNR_Fm4jPBXkhUx-Vf0GI4L_TZdCurB5k,35116
30
+ ipfabric_netbox/models.py,sha256=CXRMO1nKlcVz_R9WbTjQ0FWsXe1C6ZHIEoRhavI8Jyo,36067
30
31
  ipfabric_netbox/navigation.py,sha256=2dEJ_wKHb52Tl0FOV1TH3JbxRe8YZ56ewrTsBFGKpCg,2210
31
32
  ipfabric_netbox/signals.py,sha256=cGa5PVD2i24pGXiVNfbu6ruIDqPVdwKQHTSWe9Ura84,1838
32
- ipfabric_netbox/tables.py,sha256=cmRFCXXK5mBLE_e6B0fsRJ4wZhkQBF1t5uoMWuQ7Eag,7573
33
+ ipfabric_netbox/tables.py,sha256=pHKv6Bosjnc-7aApak3nLhzxqBiA30olPdaMO8F1PQg,8262
33
34
  ipfabric_netbox/template_content.py,sha256=bP2nUf5MM-GUbtQxJ8QoYVNXhrDCrkb8wZnbeqa07EA,315
34
35
  ipfabric_netbox/templates/ipfabric_netbox/inc/clone_form.html,sha256=K-2TTDaS1F4wUIR8FFFPqex4KJbySXtHiz5V-OEwelY,967
35
36
  ipfabric_netbox/templates/ipfabric_netbox/inc/diff.html,sha256=xOiIrvRIBtqDD65u6xcLo2xdwDKNpAylDmzznaJRGCw,3281
@@ -42,7 +43,7 @@ ipfabric_netbox/templates/ipfabric_netbox/inc/snapshotdata.html,sha256=1ItOCPjjp
42
43
  ipfabric_netbox/templates/ipfabric_netbox/inc/transform_map_field_map.html,sha256=mRU-rBweVFvaPFHbVYPw7vcYyXiVaXCOkeHm7xWdKPA,500
43
44
  ipfabric_netbox/templates/ipfabric_netbox/inc/transform_map_relationship_map.html,sha256=qyuG_EXZMGUscA3sv_6WGSrf_bR7JTRlKyMYf6EYyo4,504
44
45
  ipfabric_netbox/templates/ipfabric_netbox/ipfabric_table.html,sha256=d5qcBeKDgh57eSZ6J1oYc--qfGmHRC6PLhrffFBQiM8,1631
45
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricingestion.html,sha256=ctneF3bVY5ZMqry3SMWpX2UEsTmIM7k5sjEgT-s7LTk,5021
46
+ ipfabric_netbox/templates/ipfabric_netbox/ipfabricingestion.html,sha256=fm_X2FLnoTS6s6AL3WmU6p3puDojROSkPG0jA4EBQeM,4435
46
47
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricsnapshot.html,sha256=hj8ORs_4mM_xTjmw3McHN-da5seC8nbbkzobn0f1TSc,3482
47
48
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricsource.html,sha256=koR_t6Mf2FhWlPZHchWsTOQDSLB7AWrqtY0TRnIzrrM,3864
48
49
  ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html,sha256=BicVS7mCP85fFZJEt46GUm5xppi1Jw3byw1el9BB2WE,4448
@@ -51,10 +52,12 @@ ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap.html,sha256=qFo_K
51
52
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap_list.html,sha256=p8zqn0-B6mawSUM3zQrus6dsKUM5SRBTO0X94pLboX8,452
52
53
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap_restore.html,sha256=cuvOBKnlMudMI-CX77W3vTpwhtc4CBcc6j6Ye-IBBMQ,2562
53
54
  ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmapgroup.html,sha256=H3uvjA4PdJq5uX2kizdHV1pAxwcPpyfc9IbJi1ZK5-k,975
54
- ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_all.html,sha256=FHMoHSnngsR_ZyypF6Ent8Tl-4kYDe1WTUIoehPYWEY,389
55
+ ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_all.html,sha256=W9W31m8b7mGrsfu0f_hE1TcudDqSJ0G2Bp9JE4UTIIE,662
55
56
  ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_progress.html,sha256=NellDFy1qzgVbCtkOZbiSi3ufau6FOLIQPUoNiU6Bg4,595
56
- ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_status.html,sha256=mKXre8_-y6OYyoL_WFYyHuyxJrR4D40M2OT-r83zKEM,81
57
+ ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_statistics.html,sha256=_KtbxZQlIa_141OQSIAvSsMMfZRkNj27kJqrYHuLt7Y,867
58
+ ipfabric_netbox/templates/ipfabric_netbox/partials/ingestion_status.html,sha256=qYXe-V-kQn1INqCHLSSpdrzXJ_IIt84AgCgAGwqpNbs,178
57
59
  ipfabric_netbox/templates/ipfabric_netbox/partials/job_logs.html,sha256=PMZxxrRVzn_JI8k5XCNV5BQnoFj9zYePN6afP6C_J2E,1622
60
+ ipfabric_netbox/templates/ipfabric_netbox/partials/object_tabs.html,sha256=3xbGy38Y1RH13bVPlcyj5L6J_FM-mOW1y0fx54hj1xE,364
58
61
  ipfabric_netbox/templates/ipfabric_netbox/partials/sync_last_ingestion.html,sha256=3v6tC88xE5cic9l9lQw7Msawoq-AW2oo2odd-osGv50,179
59
62
  ipfabric_netbox/templates/static/ipfabric_netbox/css/rack.css,sha256=z1H-RmmsqF2pGdhn5J64TMFiccy62xZUHHlCRd8fJrQ,118
60
63
  ipfabric_netbox/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -63,11 +66,11 @@ ipfabric_netbox/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
63
66
  ipfabric_netbox/tests/test_models.py,sha256=4SGMX0LZ_fThkc-F1AMZIQbpmJvQNVFnS6QYZpIZkNA,14998
64
67
  ipfabric_netbox/urls.py,sha256=ok66LP09rYi01qJmwdGGlBzV9wrGWVwVAIngPcreJxg,5449
65
68
  ipfabric_netbox/utilities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
- ipfabric_netbox/utilities/ipfutils.py,sha256=LNFNfwTAUkHb09PRExZHfRdiSubWvGHuZzvhBWHgA7Q,28675
69
+ ipfabric_netbox/utilities/ipfutils.py,sha256=u7fn4HaBwhs8CXuoUsYv1CfFfSLN3JYc2ByeN0kjF8g,31370
67
70
  ipfabric_netbox/utilities/logging.py,sha256=GYknjocMN6LQ2873_az3y0RKm29TCXaWviUIIneH-x0,3445
68
71
  ipfabric_netbox/utilities/nbutils.py,sha256=kFBEiJOGvr_49hJWCS2duXojx2-A9kVk0Xp_vj0ohfs,2641
69
72
  ipfabric_netbox/utilities/transform_map.py,sha256=QotbGc2TksINJrb62STgAigpC5Nsgi5umYHu_0rZd8k,2204
70
- ipfabric_netbox/views.py,sha256=tPuceZgH2veu5ls4_avUiM9fuM5pYxr1KFWp5im5b0s,35898
71
- ipfabric_netbox-4.1.0b3.dist-info/METADATA,sha256=1RxqnpWrxYvqQai2wU5lbiUzIhhSFUwXmyS5dy8eVj8,4548
72
- ipfabric_netbox-4.1.0b3.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
73
- ipfabric_netbox-4.1.0b3.dist-info/RECORD,,
73
+ ipfabric_netbox/views.py,sha256=nGWO-PlTyKviBzGEdmE3C_1I-xFjZMldqOWGx12wdbg,36855
74
+ ipfabric_netbox-4.1.0b5.dist-info/METADATA,sha256=MeNMpz-OvdEyzOrg39PNkuGqbyXaKTVdHUR2Y8v4psg,4548
75
+ ipfabric_netbox-4.1.0b5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
76
+ ipfabric_netbox-4.1.0b5.dist-info/RECORD,,