diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 7b54d9248..2a1ecd5d0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 7af2cf0b8..6a3f81e1e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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 diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 44c8f403f..3b502cab2 100644 --- a/docs/models/extras/customlink.md +++ b/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: diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 41c551d9c..98ee0b2e0 100644 --- a/docs/release-notes/version-3.0.md +++ b/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 diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3d23cde5c..2b9d9734c 100644 --- a/netbox/dcim/api/views.py +++ b/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 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f8fbab86b..acea294f8 100644 --- a/netbox/dcim/choices.py +++ b/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'), )), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6f2c23c90..df7f415e2 100644 --- a/netbox/dcim/filtersets.py +++ b/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): diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index f8e7bdf10..5601bc591 100644 --- a/netbox/dcim/svg.py +++ b/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): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 002a4513f..e5977b760 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2db9d0267..11b8b3948 100644 --- a/netbox/netbox/settings.py +++ b/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() diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 41a8cee25..4baf2e0e9 100644 --- a/netbox/netbox/views/generic.py +++ b/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) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 24524fad3..cc12e4855 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index be7ed66c0..a67c6cbd8 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/racks.ts b/netbox/project-static/src/racks.ts index 83d7abc14..cae9897fc 100644 --- a/netbox/project-static/src/racks.ts +++ b/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 = ` ${text}`; -} - -/** - * Show all rack images. - */ -function showRackImages(): void { - for (const elevation of getElements('.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('.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, +function setRackView( + view: RackViewSelection, + elevation: HTMLObjectElement, ): void { - const initiallyHidden = state.get('hidden'); - state.set('hidden', !initiallyHidden); - const hidden = state.get('hidden'); - - if (hidden) { - hideRackImages(); - } else { - showRackImages(); + 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; + } + } +} + +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 - * text and display state of images with the local state. + * Change the visibility of all racks in response to selection. + */ +function handleRackViewSelect( + newView: RackViewSelection, + state: StateManager, +): void { + state.set('view', newView); + for (const elevation of getElements('.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 { - const initiallyHidden = rackImagesState.get('hidden'); - for (const button of getElements('button.toggle-images')) { - toggleRackImagesButton(initiallyHidden, button); + const initialView = rackImagesState.get('view'); - button.addEventListener( - 'click', + for (const control of getElements('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('.rack_elevation')) { element.addEventListener('load', () => { - if (initiallyHidden) { - hideRackImages(); - } else if (!initiallyHidden) { - showRackImages(); - } + setRackView(initialView, element); }); } } diff --git a/netbox/project-static/src/stores/rackImages.ts b/netbox/project-static/src/stores/rackImages.ts index beeb25bce..d32833b20 100644 --- a/netbox/project-static/src/stores/rackImages.ts +++ b/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 }, ); diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index bf9a11819..5d44e2125 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -18,10 +18,6 @@ {% endblock %} {% block extra_controls %} - Previous @@ -271,6 +267,13 @@ {% plugin_left_page object %}
+
+ +
diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 468d44f76..312b543a6 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -7,9 +7,13 @@ {% block controls %}
- +
+ +
Front Rear diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 2d65dc88e..c61cc92c5 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -3,6 +3,10 @@ {% block title %}{{ report.name }}{% endblock %} +{% block object_identifier %} + {{ report.full_name }} +{% endblock %} + {% block breadcrumbs %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 2c59afe6d..3a50e09a1 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -5,6 +5,10 @@ {% block title %}{{ script }}{% endblock %} +{% block object_identifier %} + {{ script.full_name }} +{% endblock %} + {% block breadcrumbs %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 7be7f811c..24285846f 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -9,15 +9,17 @@ {# Breadcrumbs #} {{ block.super }} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 496b0a8a6..143c8c70b 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -13,7 +13,7 @@ - {% endif %} diff --git a/requirements.txt b/requirements.txt index 2aa9b175b..8aa3b8a5c 100644 --- a/requirements.txt +++ b/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