Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-10-07 14:20:42 -04:00
commit 6463fd902c
17 changed files with 166 additions and 118 deletions

View File

@ -1,8 +1,8 @@
# Custom Links # Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system. Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object. Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
For example, you might define a link like this: For example, you might define a link like this:

View File

@ -1,5 +1,21 @@
# NetBox v3.0 # NetBox v3.0
## v3.0.7 (FUTURE)
### Enhancements
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
### Bug Fixes
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
---
## v3.0.6 (2021-10-06) ## v3.0.6 (2021-10-06)
### Enhancements ### Enhancements

View File

@ -2,7 +2,7 @@ import socket
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.http import HttpResponseForbidden, HttpResponse from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.openapi import Parameter from drf_yasg.openapi import Parameter
@ -17,10 +17,10 @@ from dcim import filtersets
from dcim.models import * from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
if not peer_device_name or not peer_interface_name: if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection # Determine local endpoint from peer interface's connection
peer_device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
name=peer_device_name
)
peer_interface = get_object_or_404( peer_interface = get_object_or_404(
Interface.objects.all(), Interface.objects.restrict(request.user, 'view'),
device__name=peer_device_name, device=peer_device,
name=peer_interface_name name=peer_interface_name
) )
local_interface = peer_interface.connected_endpoint endpoint = peer_interface.connected_endpoint
if local_interface is None: # If an Interface, return the parent device
return Response() if type(endpoint) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data) # Connected endpoint is none or not an Interface
raise Http404

View File

@ -192,6 +192,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b' TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a' TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b' TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_OTHER = 'other' TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'), (TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'), (TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'), (TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
)), )),
('Other', ( ('Other', (
(TYPE_OTHER, 'Other'), (TYPE_OTHER, 'Other'),
@ -337,6 +339,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b' TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a' TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b' TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b' TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b' TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC) # Direct current (DC)
@ -444,6 +447,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'), (TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'), (TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'), (TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
(TYPE_USB_3_B, 'USB 3.0 Type B'), (TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'), (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)), )),

View File

@ -112,6 +112,9 @@ class RackElevationSVG:
) )
image.fit(scale='slice') image.fit(scale='slice')
link.add(image) link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text): def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked") rect = drawing.rect(start, end, class_="slot blocked")
@ -129,6 +132,9 @@ class RackElevationSVG:
) )
image.fit(scale='slice') image.fit(scale='slice')
drawing.add(image) drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod @staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

View File

@ -1,4 +1,5 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
super().setUp() super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Site 1', slug='site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
self.devicetype1 = DeviceType.objects.create( devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.devicetype2 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
)
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
)
self.device1 = Device.objects.create( self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1 device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
) )
self.device2 = Device.objects.create( self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1 device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
) )
self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2) cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable.save() cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self): def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list') url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name) self.assertEqual(response.data['name'], self.device2.name)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
class VirtualChassisTest(APIViewTestCases.APIViewTestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase):

View File

@ -282,14 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
if '_addanother' in request.POST: if '_addanother' in request.POST:
redirect_url = request.path redirect_url = request.get_full_path()
return_url = request.GET.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
redirect_url = f'{redirect_url}?return_url={return_url}'
# If the object has clone_fields, pre-populate a new instance of the form # If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'): if hasattr(obj, 'clone_fields'):
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}" redirect_url += f"{'&' if '?' in redirect_url else '?'}{prepare_cloned_fields(obj)}"
return redirect(redirect_url) return redirect(redirect_url)
@ -880,6 +877,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
initial_data['device'] = request.GET.get('device') initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET: elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type') initial_data['device_type'] = request.GET.get('device_type')
elif 'virtual_machine' in request.GET:
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
form = self.form(model, initial=initial_data) form = self.form(model, initial=initial_data)
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)

Binary file not shown.

Binary file not shown.

View File

@ -1,92 +1,90 @@
import { rackImagesState } from './stores'; import { rackImagesState, RackViewSelection } from './stores';
import { getElements } from './util'; import { getElements } from './util';
import type { StateManager } from './state'; import type { StateManager } from './state';
type RackToggleState = { hidden: boolean }; export type RackViewState = { view: RackViewSelection };
/** /**
* Toggle the Rack Image button to reflect the current state. If the current state is hidden and * Show or hide images and labels to build the desired rack view.
* the images are therefore hidden, the button should say "Show Images". Likewise, if the current
* state is *not* hidden, and therefore the images are shown, the button should say "Hide Images".
*
* @param hidden Current State - `true` if images are hidden, `false` otherwise.
* @param button Button element.
*/ */
function toggleRackImagesButton(hidden: boolean, button: HTMLButtonElement): void { function setRackView(
const text = hidden ? 'Show Images' : 'Hide Images'; view: RackViewSelection,
const selected = hidden ? '' : 'selected'; elevation: HTMLObjectElement,
button.setAttribute('selected', selected);
button.innerHTML = `<i class="mdi mdi-file-image-outline"></i>&nbsp;${text}`;
}
/**
* Show all rack images.
*/
function showRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.remove('hidden');
}
}
}
/**
* Hide all rack images.
*/
function hideRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.add('hidden');
}
}
}
/**
* Toggle the visibility of device images and update the toggle button style.
*/
function handleRackImageToggle(
target: HTMLButtonElement,
state: StateManager<RackToggleState>,
): void { ): void {
const initiallyHidden = state.get('hidden'); switch(view) {
state.set('hidden', !initiallyHidden); case 'images-and-labels': {
const hidden = state.get('hidden'); showRackElements('image.device-image', elevation);
showRackElements('text.device-image-label', elevation);
if (hidden) { break;
hideRackImages(); }
} else { case 'images-only': {
showRackImages(); showRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
case 'labels-only': {
hideRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
}
}
function showRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.remove('hidden');
}
}
function hideRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.add('hidden');
} }
toggleRackImagesButton(hidden, target);
} }
/** /**
* Add onClick callback for toggling rack elevation images. Synchronize the image toggle button * Change the visibility of all racks in response to selection.
* text and display state of images with the local state. */
function handleRackViewSelect(
newView: RackViewSelection,
state: StateManager<RackViewState>,
): void {
state.set('view', newView);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
setRackView(newView, elevation);
}
}
/**
* Add change callback for selecting rack elevation images, and set
* initial state of select and the images themselves
*/ */
export function initRackElevation(): void { export function initRackElevation(): void {
const initiallyHidden = rackImagesState.get('hidden'); const initialView = rackImagesState.get('view');
for (const button of getElements<HTMLButtonElement>('button.toggle-images')) {
toggleRackImagesButton(initiallyHidden, button);
button.addEventListener( for (const control of getElements<HTMLSelectElement>('select.rack-view')) {
'click', control.selectedIndex = [...control.options].findIndex(o => o.value == initialView);
control.addEventListener(
'change',
event => { event => {
handleRackImageToggle(event.currentTarget as HTMLButtonElement, rackImagesState); handleRackViewSelect((event.currentTarget as any).value as RackViewSelection, rackImagesState);
}, },
false, false,
); );
} }
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) { for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
element.addEventListener('load', () => { element.addEventListener('load', () => {
if (initiallyHidden) { setRackView(initialView, element);
hideRackImages();
} else if (!initiallyHidden) {
showRackImages();
}
}); });
} }
} }

View File

@ -1,6 +1,8 @@
import { createState } from '../state'; import { createState } from '../state';
export const rackImagesState = createState<{ hidden: boolean }>( export type RackViewSelection = 'images-and-labels' | 'images-only' | 'labels-only';
{ hidden: false },
export const rackImagesState = createState<{ view: RackViewSelection }>(
{ view: 'images-and-labels' },
{ persist: true }, { persist: true },
); );

View File

@ -18,10 +18,6 @@
{% endblock %} {% endblock %}
{% block extra_controls %} {% block extra_controls %}
<button class="btn btn-sm btn-outline-primary toggle-images" selected="selected">
<i class="mdi mdi-file-image-outline"></i>
Hide Images
</button>
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}"> <a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous <i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
</a> </a>
@ -271,6 +267,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-12 col-xl-7"> <div class="col col-12 col-xl-7">
<div class="text-end mb-4">
<select class="btn btn-sm btn-outline-dark rack-view">
<option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="row" style="margin-bottom: 20px"> <div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center"> <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px"> <div style="margin-left: 30px">

View File

@ -7,9 +7,13 @@
{% block controls %} {% block controls %}
<div class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
<button class="btn btn-sm btn-outline-dark toggle-images" selected="selected"> <div class="btn-group btn-group-sm" role="group">
<span class="mdi mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images <select class="btn btn-sm btn-outline-secondary rack-view">
</button> <option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>

View File

@ -3,6 +3,10 @@
{% block title %}{{ report.name }}{% endblock %} {% block title %}{{ report.name }}{% endblock %}
{% block object_identifier %}
{{ report.full_name }}
{% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>

View File

@ -5,6 +5,10 @@
{% block title %}{{ script }}{% endblock %} {% block title %}{{ script }}{% endblock %}
{% block object_identifier %}
{{ script.full_name }}
{% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>

View File

@ -9,15 +9,17 @@
{# Breadcrumbs #} {# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb"> <nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<div class="float-end"> <div class="float-end">
<code class="text-muted" title="Object type and ID"> <code class="text-muted">
{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }} {% block object_identifier %}
{% if object.slug %}({{ object.slug }}){% endif %} {{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
</code> {% if object.slug %}({{ object.slug }}){% endif %}
{% endblock object_identifier %}
</code>
</div> </div>
<ol class="breadcrumb"> <ol class="breadcrumb">
{% block breadcrumbs %} {% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li> <li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>
{% endblock %} {% endblock breadcrumbs %}
</ol> </ol>
</nav> </nav>
{{ block.super }} {{ block.super }}

View File

@ -13,7 +13,7 @@
<button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm"> <button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename <span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtualmachine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm"> <button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}