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,248 @@
1
+ """
2
+ Compare a device with its device type.
3
+
4
+ build_plan() returns a SyncPlan: the device fields that differ, and for each
5
+ component type the components to create, the components whose properties
6
+ differ, and the template names that clash with components of installed
7
+ modules. Building a plan never changes the database.
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from .components import (
13
+ COMPONENT_KINDS,
14
+ DEVICE_FIELDS,
15
+ EMPTY,
16
+ ComponentKind,
17
+ DeviceField,
18
+ SyncContext,
19
+ display_field,
20
+ )
21
+
22
+ __all__ = (
23
+ 'ComponentDiff',
24
+ 'DeviceFieldDiff',
25
+ 'KindDiff',
26
+ 'PropertyChange',
27
+ 'PropertySummary',
28
+ 'SyncPlan',
29
+ 'TemplateToCreate',
30
+ 'build_plan',
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class PropertyChange:
36
+ """A property of one component whose value differs from its template."""
37
+
38
+ current: str
39
+ desired: str
40
+
41
+
42
+ @dataclass
43
+ class ComponentDiff:
44
+ """A device component whose properties differ from its template."""
45
+
46
+ name: str
47
+ component: object
48
+ template: object
49
+ changes: dict # property key -> PropertyChange
50
+
51
+
52
+ @dataclass
53
+ class TemplateToCreate:
54
+ """A template that has no component on the device."""
55
+
56
+ name: str
57
+ template: object
58
+
59
+
60
+ @dataclass
61
+ class Conflict:
62
+ """A template whose name is taken by a component of an installed module."""
63
+
64
+ name: str
65
+ component: object
66
+
67
+
68
+ @dataclass
69
+ class PropertySummary:
70
+ """A property and all the components of one type on which it differs."""
71
+
72
+ prop: object
73
+ label: str
74
+ selection_key: str
75
+ components: list # (component name, PropertyChange)
76
+
77
+ @property
78
+ def count(self):
79
+ return len(self.components)
80
+
81
+
82
+ @dataclass
83
+ class KindDiff:
84
+ """The differences for one component type."""
85
+
86
+ kind: ComponentKind
87
+ create: list = field(default_factory=list)
88
+ update: list = field(default_factory=list)
89
+ conflicts: list = field(default_factory=list)
90
+
91
+ @property
92
+ def is_empty(self):
93
+ return not (self.create or self.update or self.conflicts)
94
+
95
+ @property
96
+ def property_summaries(self):
97
+ summaries = []
98
+ for prop in self.kind.properties:
99
+ components = [
100
+ (diff.name, diff.changes[prop.key])
101
+ for diff in self.update
102
+ if prop.key in diff.changes
103
+ ]
104
+ if components:
105
+ summaries.append(
106
+ PropertySummary(
107
+ prop=prop,
108
+ label=str(prop.label(self.kind.component_model)),
109
+ selection_key=f'{self.kind.key}.{prop.key}',
110
+ components=components,
111
+ )
112
+ )
113
+ return summaries
114
+
115
+
116
+ @dataclass
117
+ class DeviceFieldDiff:
118
+ """A device field whose value differs from the device type."""
119
+
120
+ field: DeviceField
121
+ label: str
122
+ selection_key: str
123
+ current: str
124
+ desired: str
125
+
126
+
127
+ @dataclass
128
+ class SyncPlan:
129
+ device: object
130
+ device_type: object
131
+ device_fields: list
132
+ kinds: list # KindDiff for every component type, in COMPONENT_KINDS order
133
+
134
+ @property
135
+ def changed_kinds(self):
136
+ return [k for k in self.kinds if not k.is_empty]
137
+
138
+ @property
139
+ def create_count(self):
140
+ return sum(len(k.create) for k in self.kinds)
141
+
142
+ @property
143
+ def selection_keys(self):
144
+ """All the property keys the user can select."""
145
+ keys = [f.selection_key for f in self.device_fields]
146
+ for kind_diff in self.kinds:
147
+ keys.extend(s.selection_key for s in kind_diff.property_summaries)
148
+ return keys
149
+
150
+ @property
151
+ def is_empty(self):
152
+ return not self.device_fields and not self.changed_kinds
153
+
154
+
155
+ def _diff_device_fields(device, device_type):
156
+ diffs = []
157
+ for device_field in DEVICE_FIELDS:
158
+ source = device_type._meta.get_field(device_field.source)
159
+ desired = getattr(device_type, source.attname)
160
+ current = getattr(device, device._meta.get_field(device_field.name).attname)
161
+ # Like Device.save(): an empty device type value is never copied
162
+ if desired in (None, '') or desired == current:
163
+ continue
164
+ diffs.append(
165
+ DeviceFieldDiff(
166
+ field=device_field,
167
+ label=str(device_field.label()),
168
+ selection_key=f'device.{device_field.key}',
169
+ current=display_field(device, device_field.name, current)
170
+ if current not in (None, '')
171
+ else EMPTY,
172
+ desired=display_field(device_type, device_field.source, desired),
173
+ )
174
+ )
175
+ return diffs
176
+
177
+
178
+ def _template_key(kind, template, ctx):
179
+ if kind.key == 'inventoryitem':
180
+ return ctx.template_path(template)
181
+ return ctx.template_name(template)
182
+
183
+
184
+ def _component_key(kind, component, ctx):
185
+ if kind.key == 'inventoryitem':
186
+ return ctx.component_path(component)
187
+ return component.name
188
+
189
+
190
+ def _key_display(key):
191
+ return ' / '.join(key) if isinstance(key, tuple) else key
192
+
193
+
194
+ def diff_kind(kind, device, ctx):
195
+ result = KindDiff(kind=kind)
196
+ templates = getattr(device.device_type, kind.template_accessor).all()
197
+ components = getattr(device, kind.component_accessor).all()
198
+
199
+ device_components = {}
200
+ module_components = {}
201
+ for component in components:
202
+ if kind.is_modular and component.module_id is not None:
203
+ module_components[component.name] = component
204
+ else:
205
+ device_components[_component_key(kind, component, ctx)] = component
206
+
207
+ for template in templates:
208
+ key = _template_key(kind, template, ctx)
209
+ name = _key_display(key)
210
+ component = device_components.get(key)
211
+ if component is None:
212
+ if key in module_components:
213
+ result.conflicts.append(
214
+ Conflict(name=name, component=module_components[key])
215
+ )
216
+ else:
217
+ result.create.append(TemplateToCreate(name=name, template=template))
218
+ continue
219
+
220
+ changes = {}
221
+ for prop in kind.properties:
222
+ desired, desired_display = prop.read_template(template, ctx)
223
+ current, current_display = prop.read_component(component, ctx)
224
+ if desired != current:
225
+ changes[prop.key] = PropertyChange(
226
+ current=current_display, desired=desired_display
227
+ )
228
+ if changes:
229
+ result.update.append(
230
+ ComponentDiff(
231
+ name=name, component=component, template=template, changes=changes
232
+ )
233
+ )
234
+
235
+ return result
236
+
237
+
238
+ def build_plan(device):
239
+ """
240
+ Compare the device with its device type and return a SyncPlan.
241
+ """
242
+ ctx = SyncContext(device)
243
+ return SyncPlan(
244
+ device=device,
245
+ device_type=device.device_type,
246
+ device_fields=_diff_device_fields(device, device.device_type),
247
+ kinds=[diff_kind(kind, device, ctx) for kind in COMPONENT_KINDS],
248
+ )
@@ -0,0 +1,35 @@
1
+ from django import forms
2
+
3
+ __all__ = ('DeviceSyncForm',)
4
+
5
+
6
+ def field_name(selection_key):
7
+ """
8
+ Return the form field name of a selection key ("interface.type" -> "interface__type").
9
+ """
10
+ return selection_key.replace('.', '__')
11
+
12
+
13
+ class DeviceSyncForm(forms.Form):
14
+ """
15
+ One unchecked checkbox for every property that differs between a device and
16
+ its device type.
17
+ """
18
+
19
+ def __init__(self, *args, plan, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self.selection_keys = plan.selection_keys
22
+ for key in self.selection_keys:
23
+ # NetBox renders BooleanField checkboxes with the form-check-input class
24
+ self.fields[field_name(key)] = forms.BooleanField(
25
+ required=False, initial=False
26
+ )
27
+
28
+ def __getitem__(self, name):
29
+ # Accept selection keys as well as field names
30
+ return super().__getitem__(field_name(name))
31
+
32
+ def selected_keys(self):
33
+ return [
34
+ key for key in self.selection_keys if self.cleaned_data.get(field_name(key))
35
+ ]
@@ -0,0 +1,239 @@
1
+ {% extends 'generic/_base.html' %}
2
+ {% load helpers %}
3
+ {% load i18n %}
4
+
5
+ {% comment %}
6
+ Context:
7
+ - object: The device
8
+ - plan: diff.SyncPlan
9
+ - form: forms.DeviceSyncForm
10
+ - errors: Error messages from a failed apply
11
+ - device_rows: (DeviceFieldDiff, bound checkbox) pairs
12
+ - kinds: {'diff': KindDiff, 'summaries': [(PropertySummary, bound checkbox)]} for changed component types
13
+ - return_url: The device page
14
+ {% endcomment %}
15
+
16
+ {% block title %}{% trans "Sync with device type" %}{% endblock %}
17
+
18
+ {% block page-header %}
19
+ <div class="container-fluid">
20
+ <div class="d-flex justify-content-between align-items-center mt-2">
21
+ <ol class="breadcrumb" aria-label="breadcrumbs">
22
+ <li class="breadcrumb-item"><a href="{% url 'dcim:device_list' %}">{% trans "Devices" %}</a></li>
23
+ <li class="breadcrumb-item">{{ object|linkify }}</li>
24
+ </ol>
25
+ </div>
26
+ </div>
27
+ {{ block.super }}
28
+ {% endblock page-header %}
29
+
30
+ {% block subtitle %}
31
+ <div class="text-secondary fs-5">
32
+ <ul class="list-inline">
33
+ <li class="list-inline-item">
34
+ <i class="mdi mdi-server" title="{% trans "Device" %}"></i> {{ object|linkify }}
35
+ </li>
36
+ <li class="list-inline-item">
37
+ <i class="mdi mdi-chip" title="{% trans "Device type" %}"></i> {{ plan.device_type|linkify }}
38
+ </li>
39
+ </ul>
40
+ </div>
41
+ {% endblock subtitle %}
42
+
43
+ {% block content %}
44
+ {% if errors %}
45
+ <div class="alert alert-danger mt-3" role="alert">
46
+ <h4 class="alert-title">{% trans "Nothing was changed. Fix these errors and try again:" %}</h4>
47
+ <ul class="mb-0">
48
+ {% for error in errors %}
49
+ <li>{{ error }}</li>
50
+ {% endfor %}
51
+ </ul>
52
+ </div>
53
+ {% endif %}
54
+
55
+ {% if plan.is_empty %}
56
+ <div class="row mt-3">
57
+ <div class="col col-md-8 offset-md-2">
58
+ <div class="card">
59
+ <div class="card-body text-center py-5">
60
+ <i class="mdi mdi-check-circle-outline text-success" style="font-size: 3rem;"></i>
61
+ <h3 class="mt-2">{% trans "The device is in sync with its device type" %}</h3>
62
+ <p class="text-secondary mb-4">
63
+ {% blocktrans trimmed with device_type=plan.device_type %}
64
+ Every component of {{ device_type }} exists on the device, and their properties match.
65
+ {% endblocktrans %}
66
+ </p>
67
+ <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Back to device" %}</a>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ {% else %}
73
+ <form action="" method="post" class="object-edit--with-sticky-actions">
74
+ {% csrf_token %}
75
+
76
+ <div class="row mt-3">
77
+ <div class="col col-lg-10 offset-lg-1">
78
+
79
+ <p class="text-secondary">
80
+ {% blocktrans trimmed %}
81
+ A checked property is copied from the device type to every component of that type with the same
82
+ name. An unchecked property keeps the value of the device. Components that are missing on the
83
+ device are always created. Cables, IP addresses, VLANs and other settings of the components are
84
+ not changed.
85
+ {% endblocktrans %}
86
+ </p>
87
+
88
+ {# Device fields #}
89
+ {% if device_rows %}
90
+ <div class="card">
91
+ <h2 class="card-header">{% trans "Device" %}</h2>
92
+ <table class="table table-hover">
93
+ <thead>
94
+ <tr>
95
+ <th class="w-1"></th>
96
+ <th>{% trans "Property" %}</th>
97
+ <th>{% trans "Device" %}</th>
98
+ <th>{% trans "Device type" %}</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ {% for diff, field in device_rows %}
103
+ <tr>
104
+ <td>{{ field }}</td>
105
+ <td><label for="{{ field.id_for_label }}" class="mb-0">{{ diff.label }}</label></td>
106
+ <td class="text-secondary">{{ diff.current }}</td>
107
+ <td>{{ diff.desired }}</td>
108
+ </tr>
109
+ {% endfor %}
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ {% endif %}
114
+
115
+ {# Component types #}
116
+ {% for kind in kinds %}
117
+ {% with diff=kind.diff %}
118
+ <div class="card">
119
+ <h2 class="card-header">
120
+ {{ diff.kind.verbose_name_plural }}
121
+ <div class="card-actions">
122
+ {% if diff.create %}
123
+ <span class="badge text-bg-success">
124
+ {% blocktrans trimmed count counter=diff.create|length %}
125
+ {{ counter }} to create
126
+ {% plural %}
127
+ {{ counter }} to create
128
+ {% endblocktrans %}
129
+ </span>
130
+ {% endif %}
131
+ {% if diff.update %}
132
+ <span class="badge text-bg-warning">
133
+ {% blocktrans trimmed count counter=diff.update|length %}
134
+ {{ counter }} with differences
135
+ {% plural %}
136
+ {{ counter }} with differences
137
+ {% endblocktrans %}
138
+ </span>
139
+ {% endif %}
140
+ </div>
141
+ </h2>
142
+
143
+ {% if diff.create or diff.conflicts %}
144
+ <div class="card-body">
145
+ {% if diff.create %}
146
+ <details>
147
+ <summary>
148
+ <i class="mdi mdi-plus-circle text-success"></i>
149
+ {% blocktrans trimmed count counter=diff.create|length with kind=diff.kind.verbose_name|lower kinds=diff.kind.verbose_name_plural|lower %}
150
+ {{ counter }} {{ kind }} will be created
151
+ {% plural %}
152
+ {{ counter }} {{ kinds }} will be created
153
+ {% endblocktrans %}
154
+ </summary>
155
+ <div class="mt-2">
156
+ {% for item in diff.create %}
157
+ <span class="badge text-bg-light me-1 mb-1">{{ item.name }}</span>
158
+ {% endfor %}
159
+ </div>
160
+ </details>
161
+ {% endif %}
162
+ {% if diff.conflicts %}
163
+ <div class="alert alert-warning mt-3 mb-0" role="alert">
164
+ <i class="mdi mdi-alert"></i>
165
+ {% trans "Not created: the name is used by a component of an installed module:" %}
166
+ {% for item in diff.conflicts %}
167
+ <strong>{{ item.name }}</strong>{% if not forloop.last %}, {% endif %}
168
+ {% endfor %}
169
+ </div>
170
+ {% endif %}
171
+ </div>
172
+ {% endif %}
173
+
174
+ {% if kind.summaries %}
175
+ <table class="table table-hover">
176
+ <thead>
177
+ <tr>
178
+ <th class="w-1"></th>
179
+ <th>{% trans "Property" %}</th>
180
+ <th>{% trans "Differs on" %}</th>
181
+ </tr>
182
+ </thead>
183
+ <tbody>
184
+ {% for summary, field in kind.summaries %}
185
+ <tr>
186
+ <td>{{ field }}</td>
187
+ <td class="w-25"><label for="{{ field.id_for_label }}" class="mb-0">{{ summary.label }}</label></td>
188
+ <td>
189
+ <details>
190
+ <summary class="text-secondary">
191
+ {% blocktrans trimmed count counter=summary.count with kind=diff.kind.verbose_name|lower kinds=diff.kind.verbose_name_plural|lower %}
192
+ {{ counter }} {{ kind }}
193
+ {% plural %}
194
+ {{ counter }} {{ kinds }}
195
+ {% endblocktrans %}
196
+ </summary>
197
+ <table class="table table-sm mt-2 mb-0">
198
+ <thead>
199
+ <tr>
200
+ <th>{% trans "Name" %}</th>
201
+ <th>{% trans "Device" %}</th>
202
+ <th>{% trans "Device type" %}</th>
203
+ </tr>
204
+ </thead>
205
+ <tbody>
206
+ {% for name, change in summary.components %}
207
+ <tr>
208
+ <td>{{ name }}</td>
209
+ <td class="text-secondary">{{ change.current }}</td>
210
+ <td>{{ change.desired }}</td>
211
+ </tr>
212
+ {% endfor %}
213
+ </tbody>
214
+ </table>
215
+ </details>
216
+ </td>
217
+ </tr>
218
+ {% endfor %}
219
+ </tbody>
220
+ </table>
221
+ {% endif %}
222
+ </div>
223
+ {% endwith %}
224
+ {% endfor %}
225
+
226
+ </div>
227
+ </div>
228
+
229
+ <div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
230
+ <div class="btn-list">
231
+ <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
232
+ <button type="submit" class="btn btn-primary">
233
+ <i class="mdi mdi-sync"></i> {% trans "Apply" %}
234
+ </button>
235
+ </div>
236
+ </div>
237
+ </form>
238
+ {% endif %}
239
+ {% endblock content %}
File without changes