netbox-plugin-device-library 0.1.0__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.
- netbox_plugin_device_library/__init__.py +21 -0
- netbox_plugin_device_library/forms.py +21 -0
- netbox_plugin_device_library/jobs.py +436 -0
- netbox_plugin_device_library/migrations/0001_initial.py +29 -0
- netbox_plugin_device_library/migrations/0002_imported_library_objects.py +109 -0
- netbox_plugin_device_library/migrations/0003_unique_import_urls.py +38 -0
- netbox_plugin_device_library/migrations/0004_images.py +24 -0
- netbox_plugin_device_library/migrations/0005_unique_image_uri.py +16 -0
- netbox_plugin_device_library/migrations/__init__.py +7 -0
- netbox_plugin_device_library/models.py +68 -0
- netbox_plugin_device_library/navigation.py +12 -0
- netbox_plugin_device_library/template_content.py +37 -0
- netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/add_from_library_button.html +71 -0
- netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/library_search_modal.html +20 -0
- netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/library_search_results.html +53 -0
- netbox_plugin_device_library/templates/netbox_plugin_device_library/settings.html +176 -0
- netbox_plugin_device_library/urls.py +21 -0
- netbox_plugin_device_library/views.py +195 -0
- netbox_plugin_device_library-0.1.0.dist-info/METADATA +30 -0
- netbox_plugin_device_library-0.1.0.dist-info/RECORD +23 -0
- netbox_plugin_device_library-0.1.0.dist-info/WHEEL +5 -0
- netbox_plugin_device_library-0.1.0.dist-info/licenses/LICENSE +225 -0
- netbox_plugin_device_library-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__author__ = "Danny Zolp"
|
|
2
|
+
__email__ = "danny@zolp.io"
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from netbox.plugins import PluginConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeviceLibraryConfig(PluginConfig):
|
|
10
|
+
name = "netbox_plugin_device_library"
|
|
11
|
+
verbose_name = "Device Library"
|
|
12
|
+
description = "A simple plugin that integrates the Netbox device library with your instance to allow for simple importing."
|
|
13
|
+
author = "Danny Zolp"
|
|
14
|
+
author_email = "danny@zolp.io"
|
|
15
|
+
version = __version__
|
|
16
|
+
base_url = "device-library"
|
|
17
|
+
min_version = "4.5.0"
|
|
18
|
+
max_version = "4.5.99"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
config = DeviceLibraryConfig
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Forms for the Device Library plugin."""
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
|
|
5
|
+
from .models import LibrarySource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LibrarySourceForm(forms.ModelForm):
|
|
9
|
+
class Meta:
|
|
10
|
+
model = LibrarySource
|
|
11
|
+
fields = ("repository",)
|
|
12
|
+
widgets = {
|
|
13
|
+
"repository": forms.URLInput(
|
|
14
|
+
attrs={
|
|
15
|
+
"class": "form-control",
|
|
16
|
+
"placeholder": "https://github.com/user/netbox-device-library",
|
|
17
|
+
"pattern": r"https://github\.com/[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]+(?:\.git)?/?",
|
|
18
|
+
"title": "Enter a HTTPS GitHub repository URL",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Background jobs for the Device Library plugin."""
|
|
2
|
+
|
|
3
|
+
import tarfile
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from time import monotonic
|
|
7
|
+
from urllib.parse import quote, unquote, urlparse
|
|
8
|
+
|
|
9
|
+
from core.events import JOB_STARTED
|
|
10
|
+
from django.core.files.base import ContentFile
|
|
11
|
+
from django.db import transaction
|
|
12
|
+
from django.utils.text import slugify
|
|
13
|
+
from extras.models import Notification
|
|
14
|
+
import requests
|
|
15
|
+
import yaml
|
|
16
|
+
from netbox.jobs import JobRunner
|
|
17
|
+
|
|
18
|
+
IMAGE_EXTENSIONS = {"bmp", "gif", "jpg", "png", "tiff", "webp"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeviceLibrarySyncJob(JobRunner):
|
|
22
|
+
"""Retrieve the configured device-library repositories for synchronization."""
|
|
23
|
+
|
|
24
|
+
class Meta:
|
|
25
|
+
name = "Synchronize device library source"
|
|
26
|
+
job_timeout = 60 * 60
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def enqueue(cls, *args, **kwargs):
|
|
30
|
+
"""Queue the job with enough time to process large library archives."""
|
|
31
|
+
kwargs.setdefault("job_timeout", cls.Meta.job_timeout)
|
|
32
|
+
return super().enqueue(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
def run(self, **kwargs):
|
|
35
|
+
"""Notify the initiating user, then synchronize configured repositories."""
|
|
36
|
+
notification = self._create_started_notification()
|
|
37
|
+
try:
|
|
38
|
+
self._sync_libraries()
|
|
39
|
+
finally:
|
|
40
|
+
# NetBox creates the terminal job notification immediately after
|
|
41
|
+
# this method returns. Remove the start notification first because
|
|
42
|
+
# a user may have only one notification per job.
|
|
43
|
+
if notification:
|
|
44
|
+
notification.delete()
|
|
45
|
+
|
|
46
|
+
def _sync_libraries(self):
|
|
47
|
+
"""Retrieve and persist the configured device-library repositories."""
|
|
48
|
+
from .models import LibrarySource
|
|
49
|
+
|
|
50
|
+
tarball_urls = {}
|
|
51
|
+
processing_results = {}
|
|
52
|
+
for repository in LibrarySource.objects.order_by("repository").values_list("repository", flat=True):
|
|
53
|
+
try:
|
|
54
|
+
self.logger.info(f"Downloading device-library repository {repository}")
|
|
55
|
+
repository_details = self._get_repository_details(repository)
|
|
56
|
+
tarball_urls[repository] = repository_details["tarball_url"]
|
|
57
|
+
processing_results[repository] = self._process_tarball(repository, repository_details)
|
|
58
|
+
except (
|
|
59
|
+
KeyError,
|
|
60
|
+
ValueError,
|
|
61
|
+
requests.RequestException,
|
|
62
|
+
tarfile.TarError,
|
|
63
|
+
yaml.YAMLError,
|
|
64
|
+
) as error:
|
|
65
|
+
self.logger.error(f"Failed to resolve {repository}: {error}")
|
|
66
|
+
# An unhandled exception marks the job errored. NetBox then sends
|
|
67
|
+
# its standard failure notification to the user who started it.
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
self.logger.info(f"Processed tarball for {repository}")
|
|
71
|
+
|
|
72
|
+
saved_counts = self._save_imported_objects(processing_results)
|
|
73
|
+
saved_counts["images"] = self._save_images(processing_results)
|
|
74
|
+
self.job.data = {
|
|
75
|
+
"tarball_urls": tarball_urls,
|
|
76
|
+
"processing_results": processing_results,
|
|
77
|
+
"saved_counts": saved_counts,
|
|
78
|
+
}
|
|
79
|
+
self.job.save(update_fields=["data"])
|
|
80
|
+
|
|
81
|
+
self.logger.info(f"Processed {len(processing_results)} device type repositories")
|
|
82
|
+
|
|
83
|
+
def _create_started_notification(self):
|
|
84
|
+
"""Create NetBox's standard Job started notification for the job owner."""
|
|
85
|
+
if not self.job.user:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return Notification.objects.create(
|
|
89
|
+
user=self.job.user,
|
|
90
|
+
object=self.job,
|
|
91
|
+
event_type=JOB_STARTED,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _save_imported_objects(processing_results: dict):
|
|
96
|
+
"""Upsert parsed library objects in PostgreSQL batches."""
|
|
97
|
+
from .models import DeviceType, ModuleType, RackType
|
|
98
|
+
|
|
99
|
+
models_by_type = {
|
|
100
|
+
"device": DeviceType,
|
|
101
|
+
"module": ModuleType,
|
|
102
|
+
"rack": RackType,
|
|
103
|
+
}
|
|
104
|
+
objects_by_type = defaultdict(list)
|
|
105
|
+
for repository_result in processing_results.values():
|
|
106
|
+
for item in repository_result["yaml_files"]:
|
|
107
|
+
model = models_by_type[item["object_type"]]
|
|
108
|
+
objects_by_type[item["object_type"]].append(
|
|
109
|
+
model(
|
|
110
|
+
manufacturer_name=item["manufacturer"] or "",
|
|
111
|
+
name=item["model"] or "",
|
|
112
|
+
part_number=item["part_number"] or "",
|
|
113
|
+
github_api_url=item["github_api_url"],
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with transaction.atomic():
|
|
118
|
+
for object_type, objects in objects_by_type.items():
|
|
119
|
+
models_by_type[object_type].objects.bulk_create(
|
|
120
|
+
objects,
|
|
121
|
+
batch_size=500,
|
|
122
|
+
update_conflicts=True,
|
|
123
|
+
update_fields=["manufacturer_name", "name", "part_number"],
|
|
124
|
+
unique_fields=["github_api_url"],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return {object_type: len(objects) for object_type, objects in objects_by_type.items()}
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _save_images(processing_results: dict):
|
|
131
|
+
"""Upsert image metadata discovered while streaming library tarballs."""
|
|
132
|
+
from .models import Image
|
|
133
|
+
|
|
134
|
+
images = [
|
|
135
|
+
Image(slug=image["slug"], face=image["face"], uri=image["uri"])
|
|
136
|
+
for repository_result in processing_results.values()
|
|
137
|
+
for image in repository_result["images"]
|
|
138
|
+
]
|
|
139
|
+
if images:
|
|
140
|
+
Image.objects.bulk_create(
|
|
141
|
+
images,
|
|
142
|
+
batch_size=500,
|
|
143
|
+
update_conflicts=True,
|
|
144
|
+
update_fields=["slug", "face"],
|
|
145
|
+
unique_fields=["uri"],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return len(images)
|
|
149
|
+
|
|
150
|
+
def _get_repository_details(self, repository: str):
|
|
151
|
+
"""Resolve GitHub details needed to process a repository's default branch."""
|
|
152
|
+
owner, name = self._get_github_repository_name(repository)
|
|
153
|
+
repository_data = requests.get(
|
|
154
|
+
f"https://api.github.com/repos/{owner}/{name}",
|
|
155
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
156
|
+
timeout=30,
|
|
157
|
+
)
|
|
158
|
+
repository_data.raise_for_status()
|
|
159
|
+
|
|
160
|
+
default_branch = repository_data.json()["default_branch"]
|
|
161
|
+
tarball_response = requests.get(
|
|
162
|
+
f"https://api.github.com/repos/{owner}/{name}/tarball/{default_branch}",
|
|
163
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
164
|
+
allow_redirects=False,
|
|
165
|
+
timeout=30,
|
|
166
|
+
)
|
|
167
|
+
tarball_response.raise_for_status()
|
|
168
|
+
return {
|
|
169
|
+
"owner": owner,
|
|
170
|
+
"name": name,
|
|
171
|
+
"default_branch": default_branch,
|
|
172
|
+
"tarball_url": tarball_response.headers["Location"],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def _process_tarball(self, repository: str, repository_details: dict):
|
|
176
|
+
"""Parse YAML files and collect image metadata from a GitHub tarball."""
|
|
177
|
+
yaml_files = []
|
|
178
|
+
images = []
|
|
179
|
+
last_progress_log = monotonic()
|
|
180
|
+
|
|
181
|
+
with requests.get(
|
|
182
|
+
repository_details["tarball_url"],
|
|
183
|
+
stream=True,
|
|
184
|
+
timeout=30,
|
|
185
|
+
) as response:
|
|
186
|
+
response.raise_for_status()
|
|
187
|
+
response.raw.decode_content = True
|
|
188
|
+
with tarfile.open(fileobj=response.raw, mode="r|gz") as archive:
|
|
189
|
+
for member in archive:
|
|
190
|
+
if not member.isfile():
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
image = self._get_image_details(member.name, repository_details)
|
|
194
|
+
if image:
|
|
195
|
+
images.append(image)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
object_type, repository_path = self._get_yaml_details(member.name)
|
|
199
|
+
if not member.name.endswith(".yaml") or object_type is None:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
file_object = archive.extractfile(member)
|
|
203
|
+
if file_object is None:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
with file_object:
|
|
207
|
+
document = yaml.safe_load(file_object)
|
|
208
|
+
|
|
209
|
+
if isinstance(document, dict):
|
|
210
|
+
yaml_files.append(
|
|
211
|
+
{
|
|
212
|
+
"object_type": object_type,
|
|
213
|
+
"manufacturer": document.get("manufacturer"),
|
|
214
|
+
"model": document.get("model"),
|
|
215
|
+
"part_number": document.get("part_number"),
|
|
216
|
+
"github_api_url": self._get_github_content_url(
|
|
217
|
+
repository_details["owner"],
|
|
218
|
+
repository_details["name"],
|
|
219
|
+
repository_path,
|
|
220
|
+
repository_details["default_branch"],
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
now = monotonic()
|
|
226
|
+
if now - last_progress_log >= 10:
|
|
227
|
+
self.logger.info(
|
|
228
|
+
f"Found {len(yaml_files)} library objects in {repository}"
|
|
229
|
+
)
|
|
230
|
+
last_progress_log = now
|
|
231
|
+
|
|
232
|
+
return {"yaml_files": yaml_files, "images": images}
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _get_yaml_details(member_name: str):
|
|
236
|
+
"""Return a YAML member's type and path relative to the Git repository."""
|
|
237
|
+
directory_types = {
|
|
238
|
+
"device-types": "device",
|
|
239
|
+
"module-types": "module",
|
|
240
|
+
"rack-types": "rack",
|
|
241
|
+
}
|
|
242
|
+
path_parts = PurePosixPath(member_name).parts
|
|
243
|
+
for index, path_part in enumerate(path_parts):
|
|
244
|
+
if path_part in directory_types:
|
|
245
|
+
return directory_types[path_part], "/".join(path_parts[index:])
|
|
246
|
+
|
|
247
|
+
return None, None
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _get_image_details(member_name: str, repository_details: dict):
|
|
251
|
+
"""Return image metadata derived from a device-library archive member."""
|
|
252
|
+
filename_parts = PurePosixPath(member_name).name.rsplit(".", 2)
|
|
253
|
+
if len(filename_parts) != 3:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
slug, face, extension = filename_parts
|
|
257
|
+
if not slug or not face or extension.lower() not in IMAGE_EXTENSIONS:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
repository_path = DeviceLibrarySyncJob._get_repository_path(member_name)
|
|
261
|
+
if repository_path is None:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"slug": slug,
|
|
266
|
+
"face": face,
|
|
267
|
+
"uri": DeviceLibrarySyncJob._get_github_content_url(
|
|
268
|
+
repository_details["owner"],
|
|
269
|
+
repository_details["name"],
|
|
270
|
+
repository_path,
|
|
271
|
+
repository_details["default_branch"],
|
|
272
|
+
),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _get_repository_path(member_name: str):
|
|
277
|
+
"""Strip the tarball's generated root directory from a repository path."""
|
|
278
|
+
repository_directories = {
|
|
279
|
+
"device-types",
|
|
280
|
+
"module-types",
|
|
281
|
+
"rack-types",
|
|
282
|
+
"elevation-images",
|
|
283
|
+
}
|
|
284
|
+
path_parts = PurePosixPath(member_name).parts
|
|
285
|
+
for index, path_part in enumerate(path_parts):
|
|
286
|
+
if path_part in repository_directories:
|
|
287
|
+
return "/".join(path_parts[index:])
|
|
288
|
+
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _get_github_content_url(owner: str, name: str, path: str, ref: str):
|
|
293
|
+
"""Build the GitHub Contents API request URL for one repository file."""
|
|
294
|
+
encoded_path = quote(path, safe="/")
|
|
295
|
+
encoded_ref = quote(ref, safe="")
|
|
296
|
+
return f"https://api.github.com/repos/{owner}/{name}/contents/{encoded_path}?ref={encoded_ref}"
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _get_github_repository_name(repository: str):
|
|
300
|
+
"""Return the GitHub owner and repository name from a repository URL."""
|
|
301
|
+
parsed_url = urlparse(repository)
|
|
302
|
+
if parsed_url.hostname not in {"github.com", "www.github.com"}:
|
|
303
|
+
raise ValueError(f"Unsupported GitHub repository URL: {repository}")
|
|
304
|
+
|
|
305
|
+
path_parts = parsed_url.path.strip("/").split("/")
|
|
306
|
+
if len(path_parts) != 2:
|
|
307
|
+
raise ValueError(f"Unsupported GitHub repository URL: {repository}")
|
|
308
|
+
|
|
309
|
+
owner, name = path_parts
|
|
310
|
+
return owner, name.removesuffix(".git")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class LibraryObjectImportJob(JobRunner):
|
|
314
|
+
"""Receive one selected library record for the next import stage."""
|
|
315
|
+
|
|
316
|
+
class Meta:
|
|
317
|
+
name = "Import device library object"
|
|
318
|
+
|
|
319
|
+
def run(self, *, record: dict, **kwargs):
|
|
320
|
+
"""Download, parse, and import one selected device-library object."""
|
|
321
|
+
response = requests.get(
|
|
322
|
+
record["github_api_url"],
|
|
323
|
+
headers={"Accept": "application/vnd.github.raw+json"},
|
|
324
|
+
timeout=30,
|
|
325
|
+
)
|
|
326
|
+
response.raise_for_status()
|
|
327
|
+
document = yaml.safe_load(response.content)
|
|
328
|
+
if not isinstance(document, dict):
|
|
329
|
+
raise ValueError("The GitHub YAML document must contain a mapping.")
|
|
330
|
+
|
|
331
|
+
image_urls = {}
|
|
332
|
+
if document.get("front_image") is True:
|
|
333
|
+
image_urls["front"] = self._get_image_uri(document, "front")
|
|
334
|
+
|
|
335
|
+
if document.get("rear_image") is True:
|
|
336
|
+
image_urls["rear"] = self._get_image_uri(document, "rear")
|
|
337
|
+
|
|
338
|
+
imported_object, created = self._import_netbox_object(record["object_type"], document)
|
|
339
|
+
images = {
|
|
340
|
+
face: self._set_object_image(imported_object, face, uri)
|
|
341
|
+
for face, uri in image_urls.items()
|
|
342
|
+
}
|
|
343
|
+
self.job.data = {
|
|
344
|
+
"record": record,
|
|
345
|
+
"imported_object": {
|
|
346
|
+
"type": record["object_type"],
|
|
347
|
+
"id": imported_object.pk,
|
|
348
|
+
"created": created,
|
|
349
|
+
"url": imported_object.get_absolute_url(),
|
|
350
|
+
},
|
|
351
|
+
"image_urls": image_urls,
|
|
352
|
+
"images": images,
|
|
353
|
+
}
|
|
354
|
+
self.job.save(update_fields=["data"])
|
|
355
|
+
self.logger.info(
|
|
356
|
+
f"Imported {record['object_type']} {document['manufacturer']} {document['model']}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _set_object_image(self, imported_object, face: str, uri: str):
|
|
360
|
+
"""Download a library image into the matching NetBox image field."""
|
|
361
|
+
field_name = f"{face}_image"
|
|
362
|
+
if not hasattr(imported_object, field_name):
|
|
363
|
+
self.logger.warning(
|
|
364
|
+
f"{imported_object._meta.verbose_name} does not support {field_name}; skipping image upload"
|
|
365
|
+
)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
response = requests.get(
|
|
369
|
+
uri,
|
|
370
|
+
headers={"Accept": "application/vnd.github.raw+json"},
|
|
371
|
+
timeout=30,
|
|
372
|
+
)
|
|
373
|
+
response.raise_for_status()
|
|
374
|
+
|
|
375
|
+
filename = unquote(urlparse(uri).path.rsplit("/", 1)[-1])
|
|
376
|
+
setattr(imported_object, field_name, ContentFile(response.content, name=filename))
|
|
377
|
+
imported_object.save(update_fields=[field_name])
|
|
378
|
+
|
|
379
|
+
self.logger.info(f"Uploaded {face} image for {imported_object}")
|
|
380
|
+
return {"field": field_name, "uri": uri}
|
|
381
|
+
|
|
382
|
+
def _get_image_uri(self, document: dict, face: str):
|
|
383
|
+
"""Look up an imported image by the YAML object's slug and face."""
|
|
384
|
+
from .models import Image
|
|
385
|
+
|
|
386
|
+
slug = document.get("slug")
|
|
387
|
+
if not slug:
|
|
388
|
+
raise ValueError(f"Cannot resolve a {face} image without a YAML slug.")
|
|
389
|
+
|
|
390
|
+
image = Image.objects.get(slug=slug, face=face)
|
|
391
|
+
self.logger.info(f"Resolved {face} image for {slug}")
|
|
392
|
+
return image.uri
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _import_netbox_object(object_type: str, document: dict):
|
|
396
|
+
"""Create or update the matching NetBox DCIM type through its ORM API."""
|
|
397
|
+
from dcim.models import DeviceType, Manufacturer, ModuleType, RackType
|
|
398
|
+
|
|
399
|
+
if object_type not in {"device", "module", "rack"}:
|
|
400
|
+
raise ValueError(f"Unsupported library object type: {object_type}")
|
|
401
|
+
|
|
402
|
+
with transaction.atomic():
|
|
403
|
+
manufacturer_name = document["manufacturer"]
|
|
404
|
+
manufacturer, _ = Manufacturer.objects.get_or_create(
|
|
405
|
+
name=manufacturer_name,
|
|
406
|
+
defaults={"slug": manufacturer_name.lower().replace(" ", "-")},
|
|
407
|
+
)
|
|
408
|
+
model = document["model"]
|
|
409
|
+
part_number = document.get("part_number", "")
|
|
410
|
+
|
|
411
|
+
if object_type == "device":
|
|
412
|
+
return DeviceType.objects.update_or_create(
|
|
413
|
+
manufacturer=manufacturer,
|
|
414
|
+
model=model,
|
|
415
|
+
defaults={
|
|
416
|
+
"slug": document.get("slug") or slugify(model),
|
|
417
|
+
"part_number": part_number,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if object_type == "module":
|
|
422
|
+
return ModuleType.objects.update_or_create(
|
|
423
|
+
manufacturer=manufacturer,
|
|
424
|
+
model=model,
|
|
425
|
+
defaults={"part_number": part_number},
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if object_type == "rack":
|
|
429
|
+
return RackType.objects.update_or_create(
|
|
430
|
+
manufacturer=manufacturer,
|
|
431
|
+
model=model,
|
|
432
|
+
defaults={
|
|
433
|
+
"slug": document.get("slug") or slugify(model),
|
|
434
|
+
"form_factor": document["form_factor"],
|
|
435
|
+
},
|
|
436
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
initial = True
|
|
6
|
+
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
operations = [
|
|
10
|
+
migrations.CreateModel(
|
|
11
|
+
name="LibrarySource",
|
|
12
|
+
fields=[
|
|
13
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
14
|
+
(
|
|
15
|
+
"repository",
|
|
16
|
+
models.URLField(
|
|
17
|
+
help_text="The HTTPS URL of a device-library GitHub repository.",
|
|
18
|
+
max_length=500,
|
|
19
|
+
unique=True,
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
options={
|
|
24
|
+
"ordering": ("repository",),
|
|
25
|
+
"verbose_name": "library source",
|
|
26
|
+
"verbose_name_plural": "library sources",
|
|
27
|
+
},
|
|
28
|
+
),
|
|
29
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
("netbox_plugin_device_library", "0001_initial"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="DeviceType",
|
|
13
|
+
fields=[
|
|
14
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
15
|
+
("manufacturer_name", models.CharField(max_length=100)),
|
|
16
|
+
("name", models.CharField(max_length=200)),
|
|
17
|
+
("part_number", models.CharField(blank=True, max_length=200)),
|
|
18
|
+
(
|
|
19
|
+
"github_api_url",
|
|
20
|
+
models.URLField(
|
|
21
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
22
|
+
max_length=500,
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
options={
|
|
27
|
+
"ordering": ("manufacturer_name", "name"),
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
migrations.CreateModel(
|
|
31
|
+
name="ModuleType",
|
|
32
|
+
fields=[
|
|
33
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
34
|
+
("manufacturer_name", models.CharField(max_length=100)),
|
|
35
|
+
("name", models.CharField(max_length=200)),
|
|
36
|
+
("part_number", models.CharField(blank=True, max_length=200)),
|
|
37
|
+
(
|
|
38
|
+
"github_api_url",
|
|
39
|
+
models.URLField(
|
|
40
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
41
|
+
max_length=500,
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
options={
|
|
46
|
+
"ordering": ("manufacturer_name", "name"),
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
migrations.CreateModel(
|
|
50
|
+
name="RackType",
|
|
51
|
+
fields=[
|
|
52
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
53
|
+
("manufacturer_name", models.CharField(max_length=100)),
|
|
54
|
+
("name", models.CharField(max_length=200)),
|
|
55
|
+
("part_number", models.CharField(blank=True, max_length=200)),
|
|
56
|
+
(
|
|
57
|
+
"github_api_url",
|
|
58
|
+
models.URLField(
|
|
59
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
60
|
+
max_length=500,
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
],
|
|
64
|
+
options={
|
|
65
|
+
"ordering": ("manufacturer_name", "name"),
|
|
66
|
+
},
|
|
67
|
+
),
|
|
68
|
+
migrations.RunSQL(
|
|
69
|
+
sql="""
|
|
70
|
+
CREATE INDEX netbox_plugin_device_library_devicetype_fts_idx
|
|
71
|
+
ON netbox_plugin_device_library_devicetype
|
|
72
|
+
USING GIN (
|
|
73
|
+
to_tsvector(
|
|
74
|
+
'english',
|
|
75
|
+
coalesce(manufacturer_name, '') || ' ' ||
|
|
76
|
+
coalesce(name, '') || ' ' ||
|
|
77
|
+
coalesce(part_number, '')
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX netbox_plugin_device_library_moduletype_fts_idx
|
|
82
|
+
ON netbox_plugin_device_library_moduletype
|
|
83
|
+
USING GIN (
|
|
84
|
+
to_tsvector(
|
|
85
|
+
'english',
|
|
86
|
+
coalesce(manufacturer_name, '') || ' ' ||
|
|
87
|
+
coalesce(name, '') || ' ' ||
|
|
88
|
+
coalesce(part_number, '')
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE INDEX netbox_plugin_device_library_racktype_fts_idx
|
|
93
|
+
ON netbox_plugin_device_library_racktype
|
|
94
|
+
USING GIN (
|
|
95
|
+
to_tsvector(
|
|
96
|
+
'english',
|
|
97
|
+
coalesce(manufacturer_name, '') || ' ' ||
|
|
98
|
+
coalesce(name, '') || ' ' ||
|
|
99
|
+
coalesce(part_number, '')
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
""",
|
|
103
|
+
reverse_sql="""
|
|
104
|
+
DROP INDEX netbox_plugin_device_library_devicetype_fts_idx;
|
|
105
|
+
DROP INDEX netbox_plugin_device_library_moduletype_fts_idx;
|
|
106
|
+
DROP INDEX netbox_plugin_device_library_racktype_fts_idx;
|
|
107
|
+
""",
|
|
108
|
+
),
|
|
109
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
("netbox_plugin_device_library", "0002_imported_library_objects"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.AlterField(
|
|
12
|
+
model_name="devicetype",
|
|
13
|
+
name="github_api_url",
|
|
14
|
+
field=models.URLField(
|
|
15
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
16
|
+
max_length=500,
|
|
17
|
+
unique=True,
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
migrations.AlterField(
|
|
21
|
+
model_name="moduletype",
|
|
22
|
+
name="github_api_url",
|
|
23
|
+
field=models.URLField(
|
|
24
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
25
|
+
max_length=500,
|
|
26
|
+
unique=True,
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
migrations.AlterField(
|
|
30
|
+
model_name="racktype",
|
|
31
|
+
name="github_api_url",
|
|
32
|
+
field=models.URLField(
|
|
33
|
+
help_text="GitHub API URL for the YAML document from which this object was imported.",
|
|
34
|
+
max_length=500,
|
|
35
|
+
unique=True,
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
("netbox_plugin_device_library", "0003_unique_import_urls"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="Image",
|
|
13
|
+
fields=[
|
|
14
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
15
|
+
("slug", models.SlugField(max_length=100)),
|
|
16
|
+
("face", models.CharField(max_length=10)),
|
|
17
|
+
("uri", models.URLField(max_length=500)),
|
|
18
|
+
],
|
|
19
|
+
options={
|
|
20
|
+
"db_table": "netbox_plugin_device_library_images",
|
|
21
|
+
"ordering": ("slug", "face"),
|
|
22
|
+
},
|
|
23
|
+
),
|
|
24
|
+
]
|