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.
- netbox_device_type_sync/__init__.py +23 -0
- netbox_device_type_sync/apply.py +402 -0
- netbox_device_type_sync/components.py +641 -0
- netbox_device_type_sync/diff.py +248 -0
- netbox_device_type_sync/forms.py +35 -0
- netbox_device_type_sync/templates/netbox_device_type_sync/device_sync.html +239 -0
- netbox_device_type_sync/tests/__init__.py +0 -0
- netbox_device_type_sync/tests/test_apply.py +427 -0
- netbox_device_type_sync/tests/test_coverage.py +174 -0
- netbox_device_type_sync/tests/test_diff.py +313 -0
- netbox_device_type_sync/tests/test_load.py +15 -0
- netbox_device_type_sync/tests/test_ui.py +111 -0
- netbox_device_type_sync/tests/test_views.py +123 -0
- netbox_device_type_sync/tests/utils.py +41 -0
- netbox_device_type_sync/ui.py +96 -0
- netbox_device_type_sync/urls.py +7 -0
- netbox_device_type_sync/version.py +1 -0
- netbox_device_type_sync/views.py +109 -0
- netbox_device_type_sync-1.0.0.dist-info/METADATA +267 -0
- netbox_device_type_sync-1.0.0.dist-info/RECORD +23 -0
- netbox_device_type_sync-1.0.0.dist-info/WHEEL +5 -0
- netbox_device_type_sync-1.0.0.dist-info/licenses/LICENSE +21 -0
- netbox_device_type_sync-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|