From 8364694fb4bf606bf920cc81e7592b868576d10e Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 15 Mar 2020 23:45:18 -0400 Subject: [PATCH] added plugin template content injection to primary model detail views --- netbox/extras/plugins/__init__.py | 87 +++++++++++++++++++ netbox/extras/plugins/signals.py | 4 +- .../extras/plugins/templatetags/__init__.py | 0 netbox/extras/templatetags/plugins.py | 55 +++++++++--- netbox/templates/circuits/circuit.html | 9 ++ netbox/templates/circuits/provider.html | 9 ++ netbox/templates/dcim/cable.html | 9 ++ netbox/templates/dcim/device.html | 7 ++ netbox/templates/dcim/devicetype.html | 9 ++ netbox/templates/dcim/powerfeed.html | 9 ++ netbox/templates/dcim/powerpanel.html | 9 ++ netbox/templates/dcim/rack.html | 9 ++ netbox/templates/dcim/rackreservation.html | 9 ++ netbox/templates/dcim/site.html | 8 ++ netbox/templates/ipam/aggregate.html | 9 ++ netbox/templates/ipam/ipaddress.html | 9 ++ netbox/templates/ipam/prefix.html | 9 ++ netbox/templates/ipam/service.html | 13 ++- netbox/templates/ipam/vlan.html | 9 ++ netbox/templates/ipam/vrf.html | 9 ++ netbox/templates/secrets/secret.html | 9 ++ netbox/templates/tenancy/tenant.html | 9 ++ netbox/templates/virtualization/cluster.html | 9 ++ .../virtualization/virtualmachine.html | 9 ++ 24 files changed, 313 insertions(+), 14 deletions(-) delete mode 100644 netbox/extras/plugins/templatetags/__init__.py diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index e69de29bb..46ca58336 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -0,0 +1,87 @@ +import collections +import inspect + +from django.core.exceptions import ImproperlyConfigured +from django.template.loader import get_template + +from extras.utils import registry +from .signals import register_detail_page_content_classes + + +class PluginTemplateContent: + """ + This class is used to register plugin content to be injected into core NetBox templates. + It contains methods that are overriden by plugin authors to return template content. + + The `model` attribute on the class defines the which model detail page this class renders + content for. It should be set as a string in the form '.'. + """ + model = None + + def __init__(self, obj): + self.obj = obj + + def render(self, template, extra_context=None): + """ + Convenience menthod for rendering the provided template name. The detail page object is automatically + passed into the template context as `obj` but an additional context dictionary may be passed as `extra_context`. + """ + context = {'obj': self.obj} + if isinstance(extra_context, dict): + context.update(extra_context) + + return get_template(template).render(context) + + def left_page(self): + """ + Content that will be rendered on the left of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def right_page(self): + """ + Content that will be rendered on the right of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def full_width_page(self): + """ + Content that will be rendered within the full width of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError + + +def register_content_classes(): + registry.plugin_template_content_classes = collections.defaultdict(list) + + responses = register_detail_page_content_classes.send('registration_event') + for receiver, response in responses: + if not isinstance(response, list): + response = [response] + for template_class in response: + if not inspect.isclass(template_class): + raise TypeError('Plugin content class {} was passes as an instance!'.format(template_class)) + if not issubclass(template_class, PluginTemplateContent): + raise TypeError('{} is not a subclass of extras.plugins.PluginTemplateContent!'.format(template_class)) + if template_class.model is None: + raise TypeError('Plugin content class {} does not define a valid model!'.format(template_class)) + + registry.plugin_template_content_classes[template_class.model].append(template_class) + + +def get_content_classes(model): + if not hasattr(registry, 'plugin_template_content_classes'): + register_content_classes() + + return registry.plugin_template_content_classes.get(model, []) diff --git a/netbox/extras/plugins/signals.py b/netbox/extras/plugins/signals.py index 7d0567b1b..0e5576b67 100644 --- a/netbox/extras/plugins/signals.py +++ b/netbox/extras/plugins/signals.py @@ -27,8 +27,8 @@ class PluginSignal(django.dispatch.Signal): """ -This signal collects templates which render buttons for object detail pages +This signal collects templates which render content for object detail pages """ -register_detail_page_buttons = PluginSignal( +register_detail_page_content_classes = PluginSignal( providing_args=[] ) diff --git a/netbox/extras/plugins/templatetags/__init__.py b/netbox/extras/plugins/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py index dc3443d0c..c4a3002e7 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/extras/templatetags/plugins.py @@ -2,26 +2,59 @@ from django import template as template_ from django.template.loader import get_template from django.utils.safestring import mark_safe -from extras.plugins.signals import register_detail_page_buttons +from extras.plugins import get_content_classes register = template_.Library() +def _get_registered_content(obj, method): + """ + Given an object and a PluginTemplateContent method name, return all the registered content for the + object's model. + """ + html = '' + + plugin_template_classes = get_content_classes(obj._meta.label_lower) + for plugin_template_class in plugin_template_classes: + plugin_template_renderer = plugin_template_class(obj) + try: + content = getattr(plugin_template_renderer, method)() + except NotImplementedError: + # This content renderer class does not define content for this method + continue + html += content + + return mark_safe(html) + + @register.simple_tag() def plugin_buttons(obj): """ Fire signal to collect all buttons registered by plugins """ - html = '' - responses = register_detail_page_buttons.send(obj) - for receiver, response in responses: - if not isinstance(response, list): - response = [response] - for template in response: - if isinstance(template, str): - template_text = get_template(template).render({'obj': obj}) - html += template_text + return _get_registered_content(obj, 'buttons') - return mark_safe(html) +@register.simple_tag() +def plugin_left_page(obj): + """ + Fire signal to collect all left page content registered by plugins + """ + return _get_registered_content(obj, 'left_page') + + +@register.simple_tag() +def plugin_right_page(obj): + """ + Fire signal to collect all right page content registered by plugins + """ + return _get_registered_content(obj, 'right_page') + + +@register.simple_tag() +def plugin_full_width_page(obj): + """ + Fire signal to collect all full width page content registered by plugins + """ + return _get_registered_content(obj, 'full_width_page') diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 4d7fe9fe2..6b380dc38 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block title %}{{ circuit }}{% endblock %} @@ -28,6 +29,7 @@
+ {% plugin_buttons circuit %} {% if perms.circuits.add_circuit %} {% clone_button circuit %} {% endif %} @@ -125,10 +127,17 @@ {% endif %}
+ {% plugin_left_page circuit %}
{% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %} + {% plugin_right_page circuit %} +
+ +
+
+ {% plugin_full_width_page circuit %}
{% endblock %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index faeb516ee..c8b6cd66f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -3,6 +3,7 @@ {% load static %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block title %}{{ provider }}{% endblock %} @@ -28,6 +29,7 @@
+ {% plugin_buttons provider %} {% if show_graphs %}
+ {% plugin_left_page provider %}
@@ -132,9 +135,15 @@ {% endif %}
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_right_page provider %}
{% include 'inc/modal.html' with name='graphs' title='Graphs' %} +
+
+ {% plugin_full_width_page provider %} +
+
{% endblock %} {% block javascript %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index a78879b23..a74debfe9 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -13,6 +14,7 @@
+ {% plugin_buttons cable %} {% if perms.dcim.change_cable %} {% edit_button cable %} {% endif %} @@ -79,6 +81,7 @@
+ {% plugin_left_page cable %}
@@ -93,6 +96,12 @@
{% include 'dcim/inc/cable_termination.html' with termination=cable.termination_b %}
+ {% plugin_right_page cable %} + + +
+
+ {% plugin_full_width_page cable %}
{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 523e3383d..34eee3f55 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -333,6 +333,7 @@ {% endif %} + {% plugin_left_page device %}
{% if console_ports or power_ports %} @@ -499,6 +500,12 @@
None found
{% endif %}
+ {% plugin_right_page device %} + + +
+
+ {% plugin_full_width_page device %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 352141a9a..4d401da88 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} @@ -16,6 +17,7 @@
+ {% plugin_buttons devicetype %} {% if perms.dcim.change_devicetype %}
+ {% plugin_left_page devicetype %}
{% include 'inc/custom_fields_panel.html' with obj=devicetype %} @@ -155,6 +158,7 @@ {% endif %}
+ {% plugin_right_page devicetype %} {% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %} @@ -167,6 +171,11 @@ {% endif %} +
+
+ {% plugin_full_width_page devicetype %} +
+
{% if devicetype.is_parent_device or devicebay_table.rows %}
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index ca717b5e1..026d97313 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -3,6 +3,7 @@ {% load static %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -31,6 +32,7 @@
+ {% plugin_buttons powerfeed %} {% if perms.dcim.add_powerfeed %} {% clone_button powerfeed %} {% endif %} @@ -123,6 +125,7 @@
{% include 'inc/custom_fields_panel.html' with obj=powerfeed %} {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %} + {% plugin_left_page powerfeed %}
@@ -164,6 +167,12 @@ {% endif %}
+ {% plugin_right_page powerfeed %} + + +
+
+ {% plugin_full_width_page powerfeed %}
{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 6d47e08b1..a2d3376d7 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -3,6 +3,7 @@ {% load custom_links %} {% load helpers %} {% load static %} +{% load plugins %} {% block header %}
@@ -30,6 +31,7 @@
+ {% plugin_buttons powerpanel %} {% if perms.dcim.change_powerpanel %} {% edit_button powerpanel %} {% endif %} @@ -80,9 +82,16 @@
+ {% plugin_left_page powerpanel %}
{% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} + {% plugin_right_page powerpanel %} +
+ +
+
+ {% plugin_full_width_page powerpanel %}
{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index ecd17172b..756f9619e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -3,6 +3,7 @@ {% load custom_links %} {% load helpers %} {% load static %} +{% load plugins %} {% block header %}
@@ -27,6 +28,7 @@
+ {% plugin_buttons rack %} Previous Rack @@ -312,6 +314,7 @@
{% endif %} + {% plugin_left_page rack %}
@@ -369,6 +372,12 @@
{% endif %}
+ {% plugin_right_page rack %} + + +
+
+ {% plugin_full_width_page rack %}
{% endblock %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ef9e49d23..81592904f 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -3,6 +3,7 @@ {% load custom_links %} {% load helpers %} {% load static %} +{% load plugins %} {% block header %}
@@ -27,6 +28,7 @@
+ {% plugin_buttons rackreservation %} {% if perms.dcim.change_rackreservation %} {% edit_button rackreservation %} {% endif %} @@ -119,6 +121,7 @@
+ {% plugin_left_page rackreservation %}
{% with rack=rackreservation.rack %} @@ -137,6 +140,12 @@
{% endwith %} + {% plugin_right_page rackreservation %} + + +
+
+ {% plugin_full_width_page rackreservation %}
{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 6a0b836f8..16cee782c 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -5,6 +5,7 @@ {% load plugins %} {% load static %} {% load tz %} +{% load plugins %} {% block header %}
@@ -214,6 +215,7 @@ {% endif %}
+ {% plugin_left_page site %}
@@ -288,9 +290,15 @@
{% endif %}
+ {% plugin_right_page site %} {% include 'inc/modal.html' with name='graphs' title='Graphs' %} +
+
+ {% plugin_full_width_page site %} +
+
{% endblock %} {% block javascript %} diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 43cfb10a0..9810e3689 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -26,6 +27,7 @@
+ {% plugin_buttons aggregate %} {% if perms.ipam.add_aggregate %} {% clone_button aggregate %} {% endif %} @@ -88,10 +90,17 @@
+ {% plugin_left_page aggregate %}
{% include 'inc/custom_fields_panel.html' with obj=aggregate %} {% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %} + {% plugin_right_page aggregate %} +
+ +
+
+ {% plugin_full_width_page aggregate %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 83c34cd6b..a627b8d69 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons ipaddress %} {% if perms.ipam.add_ipaddress %} {% clone_button ipaddress %} {% endif %} @@ -152,6 +154,7 @@
{% include 'inc/custom_fields_panel.html' with obj=ipaddress %} {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %} + {% plugin_left_page ipaddress %}
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} @@ -159,6 +162,12 @@ {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} {% endif %} {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %} + {% plugin_right_page ipaddress %}
+
+
+ {% plugin_full_width_page ipaddress %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 5d5490937..d6d7ef4d8 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons prefix %} {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} Add Child Prefix @@ -187,12 +189,19 @@
{% include 'inc/custom_fields_panel.html' with obj=prefix %} {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %} + {% plugin_left_page prefix %}
{% if duplicate_prefix_table.rows %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} {% endif %} {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} + {% plugin_right_page prefix %} +
+ +
+
+ {% plugin_full_width_page prefix %}
{% endblock %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index b845aca17..2d4e69fa5 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block content %}
@@ -26,6 +27,7 @@
+ {% plugin_buttons service %} {% if perms.dcim.change_service %} {% edit_button service %} {% endif %} @@ -81,6 +83,15 @@
{% include 'inc/custom_fields_panel.html' with obj=service %} {% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %} - + {% plugin_left_page service %} + +
+ {% plugin_right_page service %} +
+ +
+
+ {% plugin_full_width_page service %} +
{% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 246f3c866..6b867762b 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -31,6 +32,7 @@
+ {% plugin_buttons vlan %} {% if perms.ipam.add_vlan %} {% clone_button vlan %} {% endif %} @@ -139,6 +141,7 @@
{% include 'inc/custom_fields_panel.html' with obj=vlan %} {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %} + {% plugin_left_page vlan %}
@@ -155,6 +158,12 @@
{% endif %}
+ {% plugin_right_page vlan %} + + +
+
+ {% plugin_full_width_page vlan %}
{% endblock %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 7bb2dea25..e448743f1 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -25,6 +26,7 @@
+ {% plugin_buttons vrf %} {% if perms.ipam.add_vrf %} {% clone_button vrf %} {% endif %} @@ -97,9 +99,16 @@
{% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %} + {% plugin_left_page vrf %}
{% include 'inc/custom_fields_panel.html' with obj=vrf %} + {% plugin_right_page vrf %} +
+ +
+
+ {% plugin_full_width_page vrf %}
{% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 6045897c9..6de32b72c 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -4,6 +4,7 @@ {% load helpers %} {% load secret_helpers %} {% load static %} +{% load plugins %} {% block header %}
@@ -16,6 +17,7 @@
+ {% plugin_buttons secret %} {% if perms.secrets.change_secret %} {% edit_button secret %} {% endif %} @@ -65,6 +67,7 @@
{% include 'inc/custom_fields_panel.html' with obj=secret %} + {% plugin_left_page secret %}
{% if secret|decryptable_by:request.user %} @@ -100,6 +103,12 @@
{% endif %} {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %} + {% plugin_right_page secret %} + + +
+
+ {% plugin_full_width_page secret %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 4ef26c451..5232d1a86 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons tenant %} {% if perms.tenancy.add_tenant %} {% clone_button tenant %} {% endif %} @@ -93,6 +95,7 @@ {% endif %}
+ {% plugin_left_page tenant %}
@@ -146,6 +149,12 @@
+ {% plugin_right_page tenant %} + + +
+
+ {% plugin_full_width_page tenant %}
{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 4070977bc..5a9d26837 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons cluster %} {% if perms.virtualization.add_cluster %} {% clone_button cluster %} {% endif %} @@ -121,6 +123,7 @@ {% endif %}
+ {% plugin_left_page cluster %}
@@ -148,6 +151,12 @@ {% endif %}
+ {% plugin_right_page cluster %}
+
+
+ {% plugin_full_width_page cluster %} +
+
{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 33dd8130a..ba6a4d33e 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -3,6 +3,7 @@ {% load custom_links %} {% load static %} {% load helpers %} +{% load plugins %} {% block header %}
@@ -28,6 +29,7 @@
+ {% plugin_buttons virtualmachine %} {% if perms.virtualization.add_virtualmachine %} {% clone_button virtualmachine %} {% endif %} @@ -158,6 +160,7 @@ {% endif %}
+ {% plugin_left_page virtualmachine %}
@@ -235,6 +238,12 @@
{% endif %}
+ {% plugin_right_page virtualmachine %} + + +
+
+ {% plugin_full_width_page virtualmachine %}