netbox-vcenter-server 0.2.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,59 @@
1
+ """
2
+ NetBox vCenter Plugin
3
+
4
+ Provides a dashboard for viewing and importing VMs from VMware vCenter servers.
5
+ Supports multiple vCenter servers with per-server caching and VM import to NetBox.
6
+ """
7
+
8
+ from netbox.plugins import PluginConfig
9
+
10
+ __version__ = "0.2.0"
11
+
12
+
13
+ class VcenterConfig(PluginConfig):
14
+ """Plugin configuration for NetBox vCenter integration."""
15
+
16
+ name = "netbox_vcenter"
17
+ verbose_name = "vCenter"
18
+ description = "View and import VMs from VMware vCenter servers"
19
+ version = __version__
20
+ author = "sieteunoseis"
21
+ author_email = "jeremy.worden@gmail.com"
22
+ base_url = "vcenter"
23
+ min_version = "4.0.0"
24
+
25
+ # Required settings - plugin won't load without these
26
+ required_settings = []
27
+
28
+ # Default configuration values
29
+ default_settings = {
30
+ # List of vCenter servers to choose from
31
+ "vcenter_servers": [],
32
+ # Connection settings
33
+ "timeout": 60, # Connection timeout in seconds (longer for MFA)
34
+ "verify_ssl": False, # SSL verification (False for self-signed certs)
35
+ # MFA/2FA settings
36
+ "mfa_enabled": False, # Whether to show MFA warning
37
+ "mfa_label": "MFA", # Short label: "Duo", "2FA", "MFA", etc.
38
+ "mfa_message": "Check your authenticator after clicking Connect & Sync.",
39
+ # Name matching for duplicate detection
40
+ # Options: "exact" (case-insensitive full name), "hostname" (strip domain), "regex" (custom pattern)
41
+ "name_match_mode": "exact",
42
+ # Regex pattern to extract the match portion from VM name (used when name_match_mode is "regex")
43
+ # Example: r"^([^.]+)" extracts hostname (same as "hostname" mode)
44
+ # Example: r"^(\w+\d+)" extracts letters followed by numbers
45
+ "name_match_pattern": r"^([^.]+)",
46
+ # Import/sync settings
47
+ # Whether to normalize VM names on import (strip domain, lowercase)
48
+ # e.g., "WebServer01.example.com" -> "webserver01"
49
+ "normalize_imported_name": True,
50
+ # Tag slug to apply to imported/synced VMs (must exist in NetBox, or leave empty)
51
+ "default_tag": "",
52
+ # Optional default role slug for imported VMs (must exist in NetBox, or leave empty)
53
+ "default_role": "",
54
+ # Optional default platform slug for imported VMs (must exist in NetBox, or leave empty)
55
+ "default_platform": "",
56
+ }
57
+
58
+
59
+ config = VcenterConfig
@@ -0,0 +1 @@
1
+ """API module for NetBox Vcenter plugin."""
@@ -0,0 +1,5 @@
1
+ """API URL configuration for NetBox Vcenter plugin."""
2
+
3
+ from django.urls import path
4
+
5
+ urlpatterns = []
@@ -0,0 +1,247 @@
1
+ """vCenter API client using pyvmomi."""
2
+
3
+ import logging
4
+ import ssl
5
+ from typing import Optional
6
+
7
+ from pyVim.connect import Disconnect, SmartConnect
8
+ from pyVmomi import vim
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class VCenterClient:
14
+ """Client for connecting to VMware vCenter and fetching VM data."""
15
+
16
+ def __init__(self, server: str, username: str, password: str, verify_ssl: bool = False):
17
+ """
18
+ Initialize vCenter client.
19
+
20
+ Args:
21
+ server: vCenter server hostname
22
+ username: vCenter username (e.g., domain\\user)
23
+ password: vCenter password
24
+ verify_ssl: Whether to verify SSL certificates
25
+ """
26
+ self.server = server
27
+ self.username = username
28
+ self.password = password
29
+ self.verify_ssl = verify_ssl
30
+ self.service_instance = None
31
+ self.content = None
32
+
33
+ def connect(self):
34
+ """
35
+ Connect to vCenter server.
36
+
37
+ Returns:
38
+ ServiceInstance object
39
+
40
+ Raises:
41
+ Exception: If connection fails
42
+ """
43
+ logger.info(f"Connecting to vCenter: {self.server}")
44
+
45
+ ssl_context = None
46
+ if not self.verify_ssl:
47
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
48
+ ssl_context.check_hostname = False
49
+ ssl_context.verify_mode = ssl.CERT_NONE
50
+
51
+ self.service_instance = SmartConnect(
52
+ host=self.server,
53
+ user=self.username,
54
+ pwd=self.password,
55
+ sslContext=ssl_context,
56
+ )
57
+
58
+ self.content = self.service_instance.RetrieveContent()
59
+ logger.info(f"Connected to vCenter: {self.content.about.fullName}")
60
+
61
+ return self.service_instance
62
+
63
+ def disconnect(self):
64
+ """Disconnect from vCenter server."""
65
+ if self.service_instance:
66
+ logger.info(f"Disconnecting from vCenter: {self.server}")
67
+ Disconnect(self.service_instance)
68
+ self.service_instance = None
69
+ self.content = None
70
+
71
+ def _get_objects_of_type(self, obj_type):
72
+ """Get all objects of a specific type from vCenter."""
73
+ view_mgr = self.content.viewManager.CreateContainerView(
74
+ self.content.rootFolder, [obj_type], True
75
+ )
76
+ try:
77
+ return list(view_mgr.view)
78
+ finally:
79
+ view_mgr.Destroy()
80
+
81
+ def get_vcenter_info(self) -> dict:
82
+ """Get vCenter server information."""
83
+ about = self.content.about
84
+ return {
85
+ "name": about.name,
86
+ "full_name": about.fullName,
87
+ "version": about.version,
88
+ "build": about.build,
89
+ "os_type": about.osType,
90
+ }
91
+
92
+ def fetch_all_vms(self) -> list:
93
+ """
94
+ Fetch all virtual machines from vCenter.
95
+
96
+ Returns:
97
+ List of VM dictionaries with details
98
+ """
99
+ logger.info(f"Fetching VMs from {self.server}")
100
+
101
+ vms = self._get_objects_of_type(vim.VirtualMachine)
102
+ vm_list = []
103
+
104
+ for vm in vms:
105
+ try:
106
+ vm_data = {
107
+ "name": vm.name,
108
+ "power_state": "on" if vm.runtime.powerState == "poweredOn" else "off",
109
+ "vcpus": None,
110
+ "memory_mb": None,
111
+ "disk_gb": None,
112
+ "cluster": None,
113
+ "datacenter": None,
114
+ "guest_os": None,
115
+ "uuid": None,
116
+ "ip_addresses": [],
117
+ "primary_ip": None,
118
+ "interfaces": [],
119
+ }
120
+
121
+ # Get hardware config
122
+ if vm.config:
123
+ vm_data["vcpus"] = vm.config.hardware.numCPU
124
+ vm_data["memory_mb"] = vm.config.hardware.memoryMB
125
+ vm_data["guest_os"] = vm.config.guestFullName
126
+ vm_data["uuid"] = vm.config.uuid
127
+
128
+ # Calculate total disk capacity
129
+ disk_devices = [
130
+ device
131
+ for device in vm.config.hardware.device
132
+ if isinstance(device, vim.vm.device.VirtualDisk)
133
+ ]
134
+ if disk_devices:
135
+ total_kb = sum(d.capacityInKB for d in disk_devices)
136
+ vm_data["disk_gb"] = round(total_kb / 1048576) # KB to GB
137
+
138
+ # Get network interfaces and IP addresses from VMware Tools
139
+ if vm.guest:
140
+ # Primary IP from guest info
141
+ if vm.guest.ipAddress:
142
+ vm_data["primary_ip"] = vm.guest.ipAddress
143
+ vm_data["ip_addresses"].append(vm.guest.ipAddress)
144
+
145
+ # Get all network interfaces
146
+ if vm.guest.net:
147
+ for nic in vm.guest.net:
148
+ interface = {
149
+ "name": nic.network or "Unknown",
150
+ "mac": nic.macAddress,
151
+ "connected": nic.connected,
152
+ "ip_addresses": [],
153
+ }
154
+ if nic.ipConfig and nic.ipConfig.ipAddress:
155
+ for ip_info in nic.ipConfig.ipAddress:
156
+ ip = ip_info.ipAddress
157
+ interface["ip_addresses"].append(ip)
158
+ if ip not in vm_data["ip_addresses"]:
159
+ vm_data["ip_addresses"].append(ip)
160
+ vm_data["interfaces"].append(interface)
161
+
162
+ # Get cluster and datacenter
163
+ if vm.runtime.host:
164
+ host = vm.runtime.host
165
+ if host.parent and isinstance(host.parent, vim.ClusterComputeResource):
166
+ vm_data["cluster"] = host.parent.name
167
+ # Walk up to find datacenter
168
+ parent = host.parent
169
+ while parent:
170
+ if isinstance(parent, vim.Datacenter):
171
+ vm_data["datacenter"] = parent.name
172
+ break
173
+ parent = getattr(parent, "parent", None)
174
+
175
+ vm_list.append(vm_data)
176
+
177
+ except Exception as e:
178
+ logger.warning(f"Error processing VM {vm.name}: {e}")
179
+ continue
180
+
181
+ logger.info(f"Fetched {len(vm_list)} VMs from {self.server}")
182
+ return vm_list
183
+
184
+ def fetch_clusters(self) -> list:
185
+ """
186
+ Fetch all clusters from vCenter.
187
+
188
+ Returns:
189
+ List of cluster dictionaries
190
+ """
191
+ clusters = self._get_objects_of_type(vim.ClusterComputeResource)
192
+ cluster_list = []
193
+
194
+ for cluster in clusters:
195
+ cluster_list.append(
196
+ {
197
+ "name": cluster.name,
198
+ "host_count": len(cluster.host) if cluster.host else 0,
199
+ }
200
+ )
201
+
202
+ return cluster_list
203
+
204
+ def fetch_datacenters(self) -> list:
205
+ """
206
+ Fetch all datacenters from vCenter.
207
+
208
+ Returns:
209
+ List of datacenter dictionaries
210
+ """
211
+ datacenters = self._get_objects_of_type(vim.Datacenter)
212
+ return [{"name": dc.name} for dc in datacenters]
213
+
214
+
215
+ def connect_and_fetch(
216
+ server: str, username: str, password: str, verify_ssl: bool = False
217
+ ) -> tuple[Optional[list], Optional[str]]:
218
+ """
219
+ Connect to vCenter and fetch all VMs.
220
+
221
+ This is a convenience function that handles connection/disconnection.
222
+
223
+ Args:
224
+ server: vCenter server hostname
225
+ username: vCenter username
226
+ password: vCenter password
227
+ verify_ssl: Whether to verify SSL certificates
228
+
229
+ Returns:
230
+ Tuple of (vm_list, error_message)
231
+ - On success: (list of VMs, None)
232
+ - On failure: (None, error message)
233
+ """
234
+ client = VCenterClient(server, username, password, verify_ssl)
235
+
236
+ try:
237
+ client.connect()
238
+ vms = client.fetch_all_vms()
239
+ return vms, None
240
+ except vim.fault.InvalidLogin as e:
241
+ logger.error(f"vCenter authentication failed: {e.msg}")
242
+ return None, f"Authentication failed: Invalid username or password"
243
+ except Exception as e:
244
+ logger.error(f"vCenter connection failed: {e}")
245
+ return None, f"Connection failed: {str(e)}"
246
+ finally:
247
+ client.disconnect()
@@ -0,0 +1,77 @@
1
+ """Forms for NetBox vCenter plugin."""
2
+
3
+ from django import forms
4
+ from django.conf import settings
5
+ from virtualization.models import Cluster
6
+
7
+
8
+ class VCenterConnectForm(forms.Form):
9
+ """Form for connecting to a vCenter server."""
10
+
11
+ server = forms.ChoiceField(
12
+ label="vCenter Server",
13
+ help_text="Select the vCenter server to connect to",
14
+ widget=forms.Select(attrs={"class": "form-select"}),
15
+ )
16
+ username = forms.CharField(
17
+ label="Username",
18
+ help_text="Enter your username (e.g., domain\\user or user@domain)",
19
+ widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "domain\\username"}),
20
+ )
21
+ password = forms.CharField(
22
+ label="Password",
23
+ widget=forms.PasswordInput(attrs={"class": "form-control"}),
24
+ )
25
+ verify_ssl = forms.BooleanField(
26
+ label="Verify SSL Certificate",
27
+ required=False,
28
+ initial=False,
29
+ help_text="Enable for trusted certificates (disable for self-signed)",
30
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
31
+ )
32
+
33
+ def __init__(self, *args, **kwargs):
34
+ super().__init__(*args, **kwargs)
35
+ # Populate server choices from plugin config
36
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
37
+ servers = config.get("vcenter_servers", [])
38
+ self.fields["server"].choices = [(s, s) for s in servers]
39
+
40
+
41
+ class VMImportForm(forms.Form):
42
+ """Form for importing VMs from vCenter to NetBox."""
43
+
44
+ cluster = forms.ModelChoiceField(
45
+ queryset=Cluster.objects.all(),
46
+ label="Target Cluster",
47
+ help_text="NetBox cluster to assign the imported VMs to",
48
+ widget=forms.Select(attrs={"class": "form-select"}),
49
+ )
50
+ selected_vms = forms.CharField(
51
+ widget=forms.HiddenInput(),
52
+ help_text="JSON list of selected VM names",
53
+ )
54
+ vcenter_server = forms.CharField(
55
+ widget=forms.HiddenInput(),
56
+ help_text="Source vCenter server",
57
+ )
58
+ update_existing = forms.BooleanField(
59
+ label="Update existing VMs",
60
+ required=False,
61
+ initial=False,
62
+ help_text="Update vCPUs, memory, disk, status, and IP address for VMs that already exist in NetBox",
63
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
64
+ )
65
+
66
+ def clean_selected_vms(self):
67
+ """Parse the selected VMs JSON string."""
68
+ import json
69
+
70
+ data = self.cleaned_data.get("selected_vms", "[]")
71
+ try:
72
+ vms = json.loads(data)
73
+ if not isinstance(vms, list):
74
+ raise forms.ValidationError("Invalid VM selection format")
75
+ return vms
76
+ except json.JSONDecodeError:
77
+ raise forms.ValidationError("Invalid VM selection data")
@@ -0,0 +1,25 @@
1
+ """Navigation menu items for NetBox vCenter plugin."""
2
+
3
+ from netbox.plugins import PluginMenu, PluginMenuItem
4
+
5
+ menu = PluginMenu(
6
+ label="vCenter",
7
+ groups=(
8
+ (
9
+ "Virtual Machines",
10
+ (
11
+ PluginMenuItem(
12
+ link="plugins:netbox_vcenter:dashboard",
13
+ link_text="Import Dashboard",
14
+ permissions=["virtualization.view_virtualmachine"],
15
+ ),
16
+ PluginMenuItem(
17
+ link="plugins:netbox_vcenter:compare",
18
+ link_text="Compare with NetBox",
19
+ permissions=["virtualization.view_virtualmachine"],
20
+ ),
21
+ ),
22
+ ),
23
+ ),
24
+ icon_class="mdi mdi-server",
25
+ )
@@ -0,0 +1,219 @@
1
+ {% extends 'base/layout.html' %}
2
+ {% load helpers %}
3
+
4
+ {% block title %}vCenter vs NetBox Comparison{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="row mb-4">
8
+ <div class="col-md-12">
9
+ <div class="card">
10
+ <div class="card-header d-flex justify-content-between align-items-center">
11
+ <h5 class="card-title mb-0">
12
+ <i class="mdi mdi-compare"></i> Compare vCenter with NetBox
13
+ </h5>
14
+ <div>
15
+ <select class="form-select form-select-sm d-inline-block w-auto" id="server-select">
16
+ <option value="">Select vCenter...</option>
17
+ {% for s in servers %}
18
+ <option value="{{ s }}" {% if s == server %}selected{% endif %}>{{ s }}</option>
19
+ {% endfor %}
20
+ </select>
21
+ </div>
22
+ </div>
23
+ <div class="card-body">
24
+ {% if not server %}
25
+ <div class="alert alert-info">
26
+ <i class="mdi mdi-information"></i>
27
+ Select a vCenter server to compare with NetBox VMs.
28
+ </div>
29
+ {% elif not cached_data %}
30
+ <div class="alert alert-warning">
31
+ <i class="mdi mdi-alert"></i>
32
+ No cached data for <strong>{{ server }}</strong>.
33
+ <a href="{% url 'plugins:netbox_vcenter:dashboard' %}?server={{ server }}">Sync first</a>.
34
+ </div>
35
+ {% else %}
36
+ <!-- Summary Stats -->
37
+ <div class="row mb-4">
38
+ <div class="col-md-3">
39
+ <div class="card bg-primary text-white">
40
+ <div class="card-body text-center">
41
+ <h3 class="mb-0">{{ in_both_count }}</h3>
42
+ <small>In Both</small>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ <div class="col-md-3">
47
+ <div class="card bg-success text-white">
48
+ <div class="card-body text-center">
49
+ <h3 class="mb-0">{{ only_vcenter_count }}</h3>
50
+ <small>Only in vCenter</small>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <div class="col-md-3">
55
+ <div class="card bg-secondary text-white">
56
+ <div class="card-body text-center">
57
+ <h3 class="mb-0">{{ only_netbox_count }}</h3>
58
+ <small>Only in NetBox</small>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ <div class="col-md-3">
63
+ <div class="card bg-danger text-white">
64
+ <div class="card-body text-center">
65
+ <h3 class="mb-0">{{ diff_count }}</h3>
66
+ <small>Spec Differences</small>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Only in vCenter (Can Import) -->
73
+ {% if comparison.only_in_vcenter %}
74
+ <h5 class="mt-4 mb-3">
75
+ <span class="badge bg-success text-white">{{ only_vcenter_count }}</span>
76
+ Only in vCenter (Can Import)
77
+ </h5>
78
+ <div class="table-responsive mb-4">
79
+ <table class="table table-sm table-hover table-striped">
80
+ <thead class="table-success">
81
+ <tr>
82
+ <th>VM Name</th>
83
+ <th>Power</th>
84
+ <th>vCPUs</th>
85
+ <th>Memory</th>
86
+ <th>Disk</th>
87
+ <th>Cluster</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {% for vm in comparison.only_in_vcenter %}
92
+ <tr>
93
+ <td><strong>{{ vm.name }}</strong></td>
94
+ <td>
95
+ {% if vm.power_state == 'on' %}
96
+ <span class="badge bg-success text-white">ON</span>
97
+ {% else %}
98
+ <span class="badge bg-secondary text-white">OFF</span>
99
+ {% endif %}
100
+ </td>
101
+ <td>{{ vm.vcpus|default:'-' }}</td>
102
+ <td>{{ vm.memory_mb|default:'-' }}</td>
103
+ <td>{{ vm.disk_gb|default:'-' }}</td>
104
+ <td>{{ vm.cluster|default:'-' }}</td>
105
+ </tr>
106
+ {% endfor %}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ {% endif %}
111
+
112
+ <!-- VMs with Differences -->
113
+ {% if diff_count > 0 %}
114
+ <div class="d-flex justify-content-between align-items-center mt-4 mb-3">
115
+ <h5 class="mb-0">
116
+ <span class="badge bg-danger text-white">{{ diff_count }}</span>
117
+ VMs with Spec Differences
118
+ </h5>
119
+ <form method="post" action="{% url 'plugins:netbox_vcenter:sync_differences' %}" class="d-inline">
120
+ {% csrf_token %}
121
+ <input type="hidden" name="server" value="{{ server }}">
122
+ <button type="submit" class="btn btn-sm btn-primary"
123
+ onclick="return confirm('Sync {{ diff_count }} VM(s) with spec differences from vCenter to NetBox?');">
124
+ <i class="mdi mdi-sync"></i> Sync All Differences
125
+ </button>
126
+ </form>
127
+ </div>
128
+ <div class="table-responsive mb-4">
129
+ <table class="table table-sm table-hover table-striped">
130
+ <thead class="table-danger">
131
+ <tr>
132
+ <th>VM Name</th>
133
+ <th>Field</th>
134
+ <th>vCenter</th>
135
+ <th>NetBox</th>
136
+ </tr>
137
+ </thead>
138
+ <tbody>
139
+ {% for vm in comparison.in_both %}
140
+ {% if vm.has_differences %}
141
+ {% if vm.vcenter.vcpus != vm.netbox.vcpus %}
142
+ <tr>
143
+ <td><strong>{{ vm.name }}</strong></td>
144
+ <td>vCPUs</td>
145
+ <td>{{ vm.vcenter.vcpus|default:'-' }}</td>
146
+ <td>{{ vm.netbox.vcpus|default:'-' }}</td>
147
+ </tr>
148
+ {% endif %}
149
+ {% if vm.vcenter.memory_mb != vm.netbox.memory_mb %}
150
+ <tr>
151
+ <td><strong>{{ vm.name }}</strong></td>
152
+ <td>Memory (MB)</td>
153
+ <td>{{ vm.vcenter.memory_mb|default:'-' }}</td>
154
+ <td>{{ vm.netbox.memory_mb|default:'-' }}</td>
155
+ </tr>
156
+ {% endif %}
157
+ {% if vm.vcenter.disk_gb != vm.netbox.disk_gb %}
158
+ <tr>
159
+ <td><strong>{{ vm.name }}</strong></td>
160
+ <td>Disk (GB)</td>
161
+ <td>{{ vm.vcenter.disk_gb|default:'-' }}</td>
162
+ <td>{{ vm.netbox.disk_gb|default:'-' }}</td>
163
+ </tr>
164
+ {% endif %}
165
+ {% endif %}
166
+ {% endfor %}
167
+ </tbody>
168
+ </table>
169
+ </div>
170
+ {% endif %}
171
+
172
+ <!-- Only in NetBox (Orphaned?) -->
173
+ {% if comparison.only_in_netbox %}
174
+ <h5 class="mt-4 mb-3">
175
+ <span class="badge bg-secondary text-white">{{ only_netbox_count }}</span>
176
+ Only in NetBox (Not in {{ server }})
177
+ </h5>
178
+ <div class="table-responsive mb-4">
179
+ <table class="table table-sm table-hover table-striped">
180
+ <thead class="table-secondary">
181
+ <tr>
182
+ <th>VM Name</th>
183
+ <th>vCPUs</th>
184
+ <th>Memory</th>
185
+ <th>NetBox Cluster</th>
186
+ </tr>
187
+ </thead>
188
+ <tbody>
189
+ {% for vm in comparison.only_in_netbox %}
190
+ <tr>
191
+ <td><strong>{{ vm.name }}</strong></td>
192
+ <td>{{ vm.vcpus|default:'-' }}</td>
193
+ <td>{{ vm.memory_mb|default:'-' }}</td>
194
+ <td>{{ vm.cluster|default:'-' }}</td>
195
+ </tr>
196
+ {% endfor %}
197
+ </tbody>
198
+ </table>
199
+ </div>
200
+ <p class="text-muted">
201
+ <i class="mdi mdi-information"></i>
202
+ These VMs exist in NetBox but were not found in <strong>{{ server }}</strong>.
203
+ They may be on a different vCenter or have been deleted.
204
+ </p>
205
+ {% endif %}
206
+ {% endif %}
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <script>
213
+ document.getElementById('server-select').addEventListener('change', function() {
214
+ if (this.value) {
215
+ window.location.href = '?server=' + this.value;
216
+ }
217
+ });
218
+ </script>
219
+ {% endblock %}