netbox-device-type-sync 1.0.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.
@@ -0,0 +1,641 @@
1
+ """
2
+ The component types and the properties that the plugin compares.
3
+
4
+ A component template defines only some properties of a component. The plugin
5
+ compares exactly those properties; everything else on a device component
6
+ (cables, IP addresses, VLANs, MAC addresses, custom fields, tags, ...) is never
7
+ read or written.
8
+
9
+ Each property knows how to read its value from a template and from a device
10
+ component, and how to write the template value to a component. Values are
11
+ compared as plain Python values; every read also returns a human-readable
12
+ display string for the sync page.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from django.utils.text import capfirst
18
+ from django.utils.translation import gettext_lazy as _
19
+
20
+ from dcim.models import (
21
+ ConsolePortTemplate,
22
+ ConsoleServerPortTemplate,
23
+ CoolingIntakeTemplate,
24
+ CoolingOutflowTemplate,
25
+ DeviceBayTemplate,
26
+ FrontPortTemplate,
27
+ InterfaceTemplate,
28
+ ModuleBayTemplate,
29
+ PowerOutletTemplate,
30
+ PowerPortTemplate,
31
+ RearPortTemplate,
32
+ )
33
+
34
+ try:
35
+ from dcim.models import InventoryItem, InventoryItemTemplate
36
+ except ImportError:
37
+ # Inventory items are deprecated since NetBox 4.3 and planned for removal.
38
+ # Without them the plugin simply does not sync inventory items.
39
+ InventoryItem = InventoryItemTemplate = None
40
+
41
+ __all__ = (
42
+ 'COMPONENT_KINDS',
43
+ 'DEVICE_FIELDS',
44
+ 'ComponentKind',
45
+ 'DeviceField',
46
+ 'EMPTY',
47
+ 'display_field',
48
+ 'SyncContext',
49
+ 'get_kind',
50
+ )
51
+
52
+ # Display string for an empty value
53
+ EMPTY = '—'
54
+
55
+
56
+ def _normalize(value):
57
+ # NetBox stores "no value" as both None and '' in optional fields
58
+ return None if value == '' else value
59
+
60
+
61
+ def display_field(obj, field_name, value):
62
+ """
63
+ Return a human-readable representation of a model field value.
64
+ """
65
+ if value is None:
66
+ return EMPTY
67
+ field = obj._meta.get_field(field_name)
68
+ if field.is_relation:
69
+ return str(getattr(obj, field_name))
70
+ if field.choices:
71
+ return str(dict(field.flatchoices).get(value, value))
72
+ if isinstance(value, bool):
73
+ return str(_('Yes')) if value else str(_('No'))
74
+ return str(value)
75
+
76
+
77
+ class SyncContext:
78
+ """
79
+ The device being synced, with lookups shared by all properties.
80
+ """
81
+
82
+ def __init__(self, device):
83
+ self.device = device
84
+ self._inventory_paths = None
85
+
86
+ def template_name(self, template):
87
+ """
88
+ Return the name a template gives a component on this device.
89
+ """
90
+ if hasattr(template, 'resolve_name'):
91
+ return template.resolve_name(device=self.device)
92
+ return template.name
93
+
94
+ def find_component(self, model, name):
95
+ """
96
+ Return the component of this device with the given model and name, or None.
97
+ """
98
+ return model.objects.filter(device=self.device, name=name).first()
99
+
100
+ #
101
+ # Inventory items have no unique name on a device; they are identified by
102
+ # the names of their parents and their own name.
103
+ #
104
+
105
+ @staticmethod
106
+ def template_path(template):
107
+ path = []
108
+ while template is not None:
109
+ path.insert(0, template.name)
110
+ template = template.parent
111
+ return tuple(path)
112
+
113
+ def component_path(self, item):
114
+ if item is None:
115
+ return None
116
+ path = []
117
+ while item is not None:
118
+ path.insert(0, item.name)
119
+ item = item.parent
120
+ return tuple(path)
121
+
122
+ def find_inventory_item(self, path):
123
+ if self._inventory_paths is None:
124
+ self._inventory_paths = {}
125
+ if path not in self._inventory_paths:
126
+ self._inventory_paths[path] = None
127
+ for item in InventoryItem.objects.filter(device=self.device, name=path[-1]):
128
+ if self.component_path(item) == path:
129
+ self._inventory_paths[path] = item
130
+ break
131
+ return self._inventory_paths[path]
132
+
133
+ def forget_inventory_items(self):
134
+ self._inventory_paths = None
135
+
136
+
137
+ #
138
+ # Properties
139
+ #
140
+
141
+
142
+ class Property:
143
+ """
144
+ A comparable property of a component.
145
+
146
+ Attributes:
147
+ key: The identifier of the property within its component type
148
+ template_fields: The template model fields covered by the property
149
+ deferred: True if the property is written after the component is saved
150
+ """
151
+
152
+ key = None
153
+ template_fields = ()
154
+ deferred = False
155
+
156
+ def label(self, component_model):
157
+ raise NotImplementedError
158
+
159
+ def read_template(self, template, ctx):
160
+ """Return (value, display) of the property for a template."""
161
+ raise NotImplementedError
162
+
163
+ def read_component(self, component, ctx):
164
+ """Return (value, display) of the property for a device component."""
165
+ raise NotImplementedError
166
+
167
+ def write(self, component, template, ctx):
168
+ """
169
+ Copy the value of the template to the component. Return an error message
170
+ if the value cannot be written, otherwise None.
171
+ """
172
+ raise NotImplementedError
173
+
174
+
175
+ class FieldProperty(Property):
176
+ """
177
+ One or more model fields that the template and the component share, for
178
+ example "type", or "diameter" together with its unit.
179
+ """
180
+
181
+ # Template fields whose value contains {vc_position} or {module} tokens
182
+ RESOLVED_FIELDS = ('label', 'position')
183
+
184
+ def __init__(self, *fields):
185
+ self.key = fields[0]
186
+ self.template_fields = fields
187
+
188
+ def label(self, component_model):
189
+ return capfirst(component_model._meta.get_field(self.key).verbose_name)
190
+
191
+ def _template_value(self, template, field_name, ctx):
192
+ resolver = getattr(template, f'resolve_{field_name}', None)
193
+ if field_name in self.RESOLVED_FIELDS and resolver is not None:
194
+ return resolver(device=ctx.device)
195
+ return getattr(template, template._meta.get_field(field_name).attname)
196
+
197
+ def _read(self, obj, values):
198
+ value = tuple(_normalize(v) for v in values)
199
+ if value[0] is None:
200
+ # A unit without a value (e.g. diameter_unit without diameter) means nothing
201
+ return (None,) * len(value), EMPTY
202
+ display = ' '.join(
203
+ display_field(obj, name, v)
204
+ for name, v in zip(self.template_fields, value)
205
+ if v is not None
206
+ )
207
+ return value, display
208
+
209
+ def read_template(self, template, ctx):
210
+ return self._read(
211
+ template,
212
+ [self._template_value(template, f, ctx) for f in self.template_fields],
213
+ )
214
+
215
+ def read_component(self, component, ctx):
216
+ return self._read(
217
+ component,
218
+ [
219
+ getattr(component, component._meta.get_field(f).attname)
220
+ for f in self.template_fields
221
+ ],
222
+ )
223
+
224
+ def write(self, component, template, ctx):
225
+ for field_name in self.template_fields:
226
+ value = self._template_value(template, field_name, ctx)
227
+ field = component._meta.get_field(field_name)
228
+ if value is None and not field.null:
229
+ value = ''
230
+ setattr(component, field.attname, value)
231
+
232
+
233
+ class RelationProperty(Property):
234
+ """
235
+ A reference to another component of the same device (for example the
236
+ parent of an interface), matched by name.
237
+ """
238
+
239
+ def __init__(self, field_name):
240
+ self.key = field_name
241
+ self.template_fields = (field_name,)
242
+
243
+ def label(self, component_model):
244
+ return capfirst(component_model._meta.get_field(self.key).verbose_name)
245
+
246
+ def read_template(self, template, ctx):
247
+ target = getattr(template, self.key)
248
+ name = ctx.template_name(target) if target is not None else None
249
+ return name, name or EMPTY
250
+
251
+ def read_component(self, component, ctx):
252
+ target = getattr(component, self.key)
253
+ name = target.name if target is not None else None
254
+ return name, name or EMPTY
255
+
256
+ def write(self, component, template, ctx):
257
+ name, _display = self.read_template(template, ctx)
258
+ if name is None:
259
+ setattr(component, self.key, None)
260
+ return None
261
+ model = component._meta.get_field(self.key).related_model
262
+ target = ctx.find_component(model, name)
263
+ if target is None:
264
+ return _('{model} {name} does not exist on the device.').format(
265
+ model=capfirst(model._meta.verbose_name), name=name
266
+ )
267
+ setattr(component, self.key, target)
268
+ return None
269
+
270
+
271
+ class InventoryParentProperty(Property):
272
+ """
273
+ The parent of an inventory item, identified by the names of its parents.
274
+ """
275
+
276
+ key = 'parent'
277
+ template_fields = ('parent',)
278
+
279
+ def label(self, component_model):
280
+ return capfirst(component_model._meta.get_field('parent').verbose_name)
281
+
282
+ @staticmethod
283
+ def _display(path):
284
+ return ' / '.join(path) if path else EMPTY
285
+
286
+ def read_template(self, template, ctx):
287
+ path = ctx.template_path(template.parent) if template.parent else None
288
+ return path, self._display(path)
289
+
290
+ def read_component(self, component, ctx):
291
+ path = ctx.component_path(component.parent)
292
+ return path, self._display(path)
293
+
294
+ def write(self, component, template, ctx):
295
+ path, display = self.read_template(template, ctx)
296
+ if path is None:
297
+ component.parent = None
298
+ return None
299
+ parent = ctx.find_inventory_item(path)
300
+ if parent is None:
301
+ return _('Inventory item {name} does not exist on the device.').format(
302
+ name=display
303
+ )
304
+ component.parent = parent
305
+ return None
306
+
307
+
308
+ class InventoryComponentProperty(Property):
309
+ """
310
+ The component an inventory item is assigned to, identified by its type and
311
+ name.
312
+ """
313
+
314
+ key = 'component'
315
+ template_fields = ('component_type', 'component_id')
316
+
317
+ def label(self, component_model):
318
+ return _('Component')
319
+
320
+ @staticmethod
321
+ def _value(model, name):
322
+ value = (model._meta.label_lower, name)
323
+ display = f'{capfirst(model._meta.verbose_name)} {name}'
324
+ return value, display
325
+
326
+ def read_template(self, template, ctx):
327
+ target = template.component
328
+ if target is None:
329
+ return None, EMPTY
330
+ return self._value(target.component_model, ctx.template_name(target))
331
+
332
+ def read_component(self, component, ctx):
333
+ target = component.component
334
+ if target is None:
335
+ return None, EMPTY
336
+ return self._value(type(target), target.name)
337
+
338
+ def write(self, component, template, ctx):
339
+ target_template = template.component
340
+ if target_template is None:
341
+ component.component = None
342
+ return None
343
+ model = target_template.component_model
344
+ name = ctx.template_name(target_template)
345
+ target = ctx.find_component(model, name)
346
+ if target is None:
347
+ return _('{model} {name} does not exist on the device.').format(
348
+ model=capfirst(model._meta.verbose_name), name=name
349
+ )
350
+ component.component = target
351
+ return None
352
+
353
+
354
+ class ModuleBayTypesProperty(Property):
355
+ """
356
+ The module bay types of a module bay (a many-to-many relation).
357
+ """
358
+
359
+ key = 'module_bay_types'
360
+ template_fields = ('module_bay_types',)
361
+ deferred = True
362
+
363
+ def label(self, component_model):
364
+ return capfirst(component_model._meta.get_field(self.key).verbose_name)
365
+
366
+ @staticmethod
367
+ def _read(manager):
368
+ types = sorted(manager.all(), key=lambda t: t.name)
369
+ value = frozenset(t.pk for t in types)
370
+ return value, ', '.join(t.name for t in types) or EMPTY
371
+
372
+ def read_template(self, template, ctx):
373
+ return self._read(template.module_bay_types)
374
+
375
+ def read_component(self, component, ctx):
376
+ return self._read(component.module_bay_types)
377
+
378
+ def write(self, component, template, ctx):
379
+ component.module_bay_types.set(template.module_bay_types.all())
380
+ return None
381
+
382
+
383
+ class PortMappingsProperty(Property):
384
+ """
385
+ The mappings of a front port to rear port positions. The mappings are
386
+ written by the apply step, which must remove obsolete mappings before port
387
+ positions change and create new mappings after.
388
+ """
389
+
390
+ key = 'mappings'
391
+ template_fields = ()
392
+ deferred = True
393
+
394
+ def label(self, component_model):
395
+ return _('Port mappings')
396
+
397
+ @staticmethod
398
+ def _display(value):
399
+ if not value:
400
+ return EMPTY
401
+ return ', '.join(
402
+ f'{front_pos} → {rear}:{rear_pos}'
403
+ for front_pos, rear, rear_pos in sorted(value)
404
+ )
405
+
406
+ @staticmethod
407
+ def desired(template, ctx):
408
+ return frozenset(
409
+ (
410
+ m.front_port_position,
411
+ ctx.template_name(m.rear_port),
412
+ m.rear_port_position,
413
+ )
414
+ for m in template.mappings.all()
415
+ )
416
+
417
+ @staticmethod
418
+ def current(component):
419
+ return frozenset(
420
+ (m.front_port_position, m.rear_port.name, m.rear_port_position)
421
+ for m in component.mappings.all()
422
+ )
423
+
424
+ def read_template(self, template, ctx):
425
+ value = self.desired(template, ctx)
426
+ return value, self._display(value)
427
+
428
+ def read_component(self, component, ctx):
429
+ value = self.current(component)
430
+ return value, self._display(value)
431
+
432
+ def write(self, component, template, ctx):
433
+ # Written by apply.sync_port_mappings()
434
+ raise NotImplementedError
435
+
436
+
437
+ #
438
+ # Component types
439
+ #
440
+
441
+
442
+ @dataclass(frozen=True)
443
+ class ComponentKind:
444
+ """
445
+ A type of device component and the properties its template defines.
446
+ """
447
+
448
+ template_model: type
449
+ properties: tuple
450
+
451
+ @property
452
+ def component_model(self):
453
+ return self.template_model.component_model
454
+
455
+ @property
456
+ def key(self):
457
+ return self.component_model._meta.model_name
458
+
459
+ @property
460
+ def template_accessor(self):
461
+ # related_name='%(class)ss' on DeviceType
462
+ return f'{self.template_model._meta.model_name}s'
463
+
464
+ @property
465
+ def component_accessor(self):
466
+ # related_name='%(class)ss' on Device
467
+ return f'{self.component_model._meta.model_name}s'
468
+
469
+ @property
470
+ def verbose_name(self):
471
+ return capfirst(self.component_model._meta.verbose_name)
472
+
473
+ @property
474
+ def verbose_name_plural(self):
475
+ return capfirst(self.component_model._meta.verbose_name_plural)
476
+
477
+ @property
478
+ def is_modular(self):
479
+ # Modular components may belong to an installed module
480
+ return any(f.name == 'module' for f in self.component_model._meta.fields)
481
+
482
+ def get_property(self, key):
483
+ for prop in self.properties:
484
+ if prop.key == key:
485
+ return prop
486
+ raise KeyError(key)
487
+
488
+
489
+ LABEL = FieldProperty('label')
490
+ DESCRIPTION = FieldProperty('description')
491
+ TYPE = FieldProperty('type')
492
+
493
+ # In the order NetBox instantiates components for a new device
494
+ # (dcim.models.Device.save()); components that others refer to come first.
495
+ COMPONENT_KINDS = (
496
+ ComponentKind(ConsolePortTemplate, (TYPE, LABEL, DESCRIPTION)),
497
+ ComponentKind(ConsoleServerPortTemplate, (TYPE, LABEL, DESCRIPTION)),
498
+ ComponentKind(
499
+ PowerPortTemplate,
500
+ (
501
+ TYPE,
502
+ FieldProperty('maximum_draw'),
503
+ FieldProperty('allocated_draw'),
504
+ LABEL,
505
+ DESCRIPTION,
506
+ ),
507
+ ),
508
+ ComponentKind(
509
+ PowerOutletTemplate,
510
+ (
511
+ TYPE,
512
+ FieldProperty('color'),
513
+ RelationProperty('power_port'),
514
+ FieldProperty('feed_leg'),
515
+ LABEL,
516
+ DESCRIPTION,
517
+ ),
518
+ ),
519
+ ComponentKind(
520
+ CoolingIntakeTemplate,
521
+ (
522
+ TYPE,
523
+ FieldProperty('diameter', 'diameter_unit'),
524
+ FieldProperty('max_flow', 'max_flow_unit'),
525
+ LABEL,
526
+ DESCRIPTION,
527
+ ),
528
+ ),
529
+ ComponentKind(
530
+ CoolingOutflowTemplate,
531
+ (
532
+ TYPE,
533
+ FieldProperty('diameter', 'diameter_unit'),
534
+ RelationProperty('cooling_intake'),
535
+ LABEL,
536
+ DESCRIPTION,
537
+ ),
538
+ ),
539
+ ComponentKind(
540
+ InterfaceTemplate,
541
+ (
542
+ TYPE,
543
+ FieldProperty('enabled'),
544
+ FieldProperty('mgmt_only'),
545
+ FieldProperty('poe_mode'),
546
+ FieldProperty('poe_type'),
547
+ FieldProperty('rf_role'),
548
+ FieldProperty('channels'),
549
+ FieldProperty('channel_id'),
550
+ RelationProperty('parent'),
551
+ RelationProperty('bridge'),
552
+ LABEL,
553
+ DESCRIPTION,
554
+ ),
555
+ ),
556
+ ComponentKind(
557
+ RearPortTemplate,
558
+ (TYPE, FieldProperty('color'), FieldProperty('positions'), LABEL, DESCRIPTION),
559
+ ),
560
+ ComponentKind(
561
+ FrontPortTemplate,
562
+ (
563
+ TYPE,
564
+ FieldProperty('color'),
565
+ FieldProperty('positions'),
566
+ PortMappingsProperty(),
567
+ LABEL,
568
+ DESCRIPTION,
569
+ ),
570
+ ),
571
+ ComponentKind(
572
+ ModuleBayTemplate,
573
+ (
574
+ FieldProperty('position'),
575
+ FieldProperty('enabled'),
576
+ ModuleBayTypesProperty(),
577
+ LABEL,
578
+ DESCRIPTION,
579
+ ),
580
+ ),
581
+ ComponentKind(DeviceBayTemplate, (FieldProperty('enabled'), LABEL, DESCRIPTION)),
582
+ )
583
+
584
+ if InventoryItemTemplate is not None:
585
+ COMPONENT_KINDS += (
586
+ ComponentKind(
587
+ InventoryItemTemplate,
588
+ (
589
+ InventoryParentProperty(),
590
+ InventoryComponentProperty(),
591
+ FieldProperty('role'),
592
+ FieldProperty('manufacturer'),
593
+ FieldProperty('part_id'),
594
+ LABEL,
595
+ DESCRIPTION,
596
+ ),
597
+ ),
598
+ )
599
+
600
+
601
+ def get_kind(key):
602
+ for kind in COMPONENT_KINDS:
603
+ if kind.key == key:
604
+ return kind
605
+ raise KeyError(key)
606
+
607
+
608
+ #
609
+ # Device fields
610
+ #
611
+
612
+
613
+ @dataclass(frozen=True)
614
+ class DeviceField:
615
+ """
616
+ A device field that NetBox copies from the device type only when a device
617
+ is created (dcim.models.Device.save()).
618
+
619
+ Attributes:
620
+ name: The field of Device
621
+ source: The field of DeviceType that holds the value
622
+ """
623
+
624
+ name: str
625
+ source: str
626
+
627
+ @property
628
+ def key(self):
629
+ return self.name
630
+
631
+ def label(self):
632
+ from dcim.models import Device
633
+
634
+ return capfirst(Device._meta.get_field(self.name).verbose_name)
635
+
636
+
637
+ DEVICE_FIELDS = (
638
+ DeviceField('platform', 'default_platform'),
639
+ DeviceField('airflow', 'airflow'),
640
+ DeviceField('cooling_method', 'cooling_method'),
641
+ )