Просмотр исходного кода

Merge pull request #7510 from netbox-community/develop

Release v3.0.7
Jeremy Stretch 4 лет назад
Родитель
Сommit
66c4d23119

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v3.0.6
+      placeholder: v3.0.7
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.6
+      placeholder: v3.0.7
     validations:
       required: true
   - type: dropdown

+ 2 - 2
docs/models/extras/customlink.md

@@ -1,8 +1,8 @@
 # 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:
 

+ 16 - 0
docs/release-notes/version-3.0.md

@@ -1,5 +1,21 @@
 # NetBox v3.0
 
+## v3.0.7 (2021-10-08)
+
+### 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)
 
 ### Enhancements

+ 19 - 9
netbox/dcim/api/views.py

@@ -2,7 +2,7 @@ import socket
 from collections import OrderedDict
 
 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 drf_yasg import openapi
 from drf_yasg.openapi import Parameter
@@ -17,10 +17,10 @@ from dcim import filtersets
 from dcim.models import *
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
 from ipam.models import Prefix, VLAN
-from netbox.api.views import ModelViewSet
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
+from netbox.api.views import ModelViewSet
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related, decode_dict
 from virtualization.models import VirtualMachine
@@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
         if not peer_device_name or not peer_interface_name:
             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(
-            Interface.objects.all(),
-            device__name=peer_device_name,
+            Interface.objects.restrict(request.user, 'view'),
+            device=peer_device,
             name=peer_interface_name
         )
-        local_interface = peer_interface.connected_endpoint
+        endpoint = peer_interface.connected_endpoint
 
-        if local_interface is None:
-            return Response()
+        # If an Interface, return the parent device
+        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

+ 4 - 0
netbox/dcim/choices.py

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

+ 9 - 0
netbox/dcim/filtersets.py

@@ -480,12 +480,21 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
 
 
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         field_name='device_type_id',
         label='Device type (ID)',
     )
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(name__icontains=value)
+
 
 class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 

+ 6 - 0
netbox/dcim/svg.py

@@ -112,6 +112,9 @@ class RackElevationSVG:
             )
             image.fit(scale='slice')
             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):
         rect = drawing.rect(start, end, class_="slot blocked")
@@ -129,6 +132,9 @@ class RackElevationSVG:
             )
             image.fit(scale='slice')
             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
     def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

+ 16 - 20
netbox/dcim/tests/test_api.py

@@ -1,4 +1,5 @@
 from django.contrib.auth.models import User
+from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
 
@@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
 
         super().setUp()
 
-        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
-        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
-        self.devicetype1 = DeviceType.objects.create(
-            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'
-        )
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
         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(
-            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.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.save()
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_get_connected_device(self):
-
         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.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):

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '3.0.6'
+VERSION = '3.0.7'
 
 # Hostname
 HOSTNAME = platform.node()

+ 4 - 5
netbox/netbox/views/generic.py

@@ -282,14 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 messages.success(request, mark_safe(msg))
 
                 if '_addanother' in request.POST:
-                    redirect_url = request.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}'
+                    redirect_url = request.get_full_path()
 
                     # If the object has clone_fields, pre-populate a new instance of the form
                     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)
 
@@ -880,6 +877,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 initial_data['device'] = request.GET.get('device')
             elif 'device_type' in request.GET:
                 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)
             restrict_form_fields(form, request.user)

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 55 - 57
netbox/project-static/src/racks.ts

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

+ 4 - 2
netbox/project-static/src/stores/rackImages.ts

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

+ 7 - 4
netbox/templates/dcim/rack.html

@@ -18,10 +18,6 @@
 {% endblock %}
 
 {% 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 %}">
     <i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
   </a>
@@ -271,6 +267,13 @@
         {% plugin_left_page object %}
 	  </div>
     <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="col col-md-6 col-sm-6 col-xs-12 text-center">
               <div style="margin-left: 30px">

+ 7 - 3
netbox/templates/dcim/rack_elevation_list.html

@@ -7,9 +7,13 @@
 {% block controls %}
     <div class="controls">
         <div class="control-group">
-            <button class="btn btn-sm btn-outline-dark toggle-images" selected="selected">
-                <span class="mdi mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images
-            </button>
+            <div class="btn-group btn-group-sm" role="group">
+                <select class="btn btn-sm btn-outline-secondary 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="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='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>

+ 4 - 0
netbox/templates/extras/report.html

@@ -3,6 +3,10 @@
 
 {% block title %}{{ report.name }}{% endblock %}
 
+{% block object_identifier %}
+  {{ report.full_name }}
+{% endblock %}
+
 {% 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' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>

+ 4 - 0
netbox/templates/extras/script.html

@@ -5,6 +5,10 @@
 
 {% block title %}{{ script }}{% endblock %}
 
+{% block object_identifier %}
+  {{ script.full_name }}
+{% endblock %}
+
 {% 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' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>

+ 7 - 5
netbox/templates/generic/object.html

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

+ 1 - 1
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -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">
                 <span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
             </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
             </button>
         {% endif %}

+ 1 - 1
requirements.txt

@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.2
 Markdown==3.3.4
 markdown-include==0.6.0
-mkdocs-material==7.3.1
+mkdocs-material==7.3.2
 netaddr==0.8.0
 Pillow==8.3.2
 psycopg2-binary==2.9.1

Некоторые файлы не были показаны из-за большого количества измененных файлов