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.
Files changed (23) hide show
  1. netbox_plugin_device_library/__init__.py +21 -0
  2. netbox_plugin_device_library/forms.py +21 -0
  3. netbox_plugin_device_library/jobs.py +436 -0
  4. netbox_plugin_device_library/migrations/0001_initial.py +29 -0
  5. netbox_plugin_device_library/migrations/0002_imported_library_objects.py +109 -0
  6. netbox_plugin_device_library/migrations/0003_unique_import_urls.py +38 -0
  7. netbox_plugin_device_library/migrations/0004_images.py +24 -0
  8. netbox_plugin_device_library/migrations/0005_unique_image_uri.py +16 -0
  9. netbox_plugin_device_library/migrations/__init__.py +7 -0
  10. netbox_plugin_device_library/models.py +68 -0
  11. netbox_plugin_device_library/navigation.py +12 -0
  12. netbox_plugin_device_library/template_content.py +37 -0
  13. netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/add_from_library_button.html +71 -0
  14. netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/library_search_modal.html +20 -0
  15. netbox_plugin_device_library/templates/netbox_plugin_device_library/partials/library_search_results.html +53 -0
  16. netbox_plugin_device_library/templates/netbox_plugin_device_library/settings.html +176 -0
  17. netbox_plugin_device_library/urls.py +21 -0
  18. netbox_plugin_device_library/views.py +195 -0
  19. netbox_plugin_device_library-0.1.0.dist-info/METADATA +30 -0
  20. netbox_plugin_device_library-0.1.0.dist-info/RECORD +23 -0
  21. netbox_plugin_device_library-0.1.0.dist-info/WHEEL +5 -0
  22. netbox_plugin_device_library-0.1.0.dist-info/licenses/LICENSE +225 -0
  23. 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
+ ]