diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..ab7d7cdc4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ“– Contributing Policy + url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md + about: Please read through our contributing policy before opening an issue or pull request + - name: ๐Ÿ’ฌ Discussion Group + url: https://groups.google.com/forum/#!forum/netbox-discuss + about: Join our discussion group for assistance with installation issues and other problems diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index fc1e89221..6deddc140 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -32,7 +32,8 @@ class DeviceIPsReport(Report): Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. ``` -from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE +from dcim.choices import DeviceStatusChoices +from dcim.constants import CONNECTION_STATUS_PLANNED from dcim.models import ConsolePort, Device, PowerPort from extras.reports import Report @@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report): def test_console_connection(self): # Check that every console port for every active device has a connection defined. - for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): + active = DeviceStatusChoices.STATUS_ACTIVE + for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active): if console_port.connected_endpoint is None: self.log_failure( console_port.device, @@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report): def test_power_connections(self): # Check that every active device has at least two connected power supplies. - for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE): + for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE): connected_ports = 0 for power_port in PowerPort.objects.filter(device=device): if power_port.connected_endpoint is not None: diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e224196ad..a7ff3a182 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,15 +1,47 @@ -# v2.7.7 (FUTURE) +# v2.7.8 (FUTURE) + +## Bug Fixes + +* [#4224](https://github.com/netbox-community/netbox/issues/4224) - Fix display of rear device image if front image is not defined +* [#4228](https://github.com/netbox-community/netbox/issues/4228) - Improve fit of device images in rack elevations +* [#4232](https://github.com/netbox-community/netbox/issues/4232) - Enforce consistent background striping in rack elevations +* [#4235](https://github.com/netbox-community/netbox/issues/4235) - Fix API representation of `content_type` for export templates + +--- + +# v2.7.7 (2020-02-20) + +**Note:** This release fixes a bug affecting the natural ordering of interfaces. If any interfaces appear unordered in +NetBox, run the following management command to recalculate their naturalized values after upgrading: + +``` +python3 manage.py renaturalize dcim.Interface +``` ## Enhancements +* [#1529](https://github.com/netbox-community/netbox/issues/1529) - Enable display of device images in rack elevations +* [#2511](https://github.com/netbox-community/netbox/issues/2511) - Compare object change to the previous change +* [#3810](https://github.com/netbox-community/netbox/issues/3810) - Preserve slug value when editing existing objects * [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment * [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings +* [#4206](https://github.com/netbox-community/netbox/issues/4206) - Add RJ-11 console port type +* [#4209](https://github.com/netbox-community/netbox/issues/4209) - Enable filtering interfaces list view by enabled ## Bug Fixes * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API +* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Fix missing migration for interface templates of type "other" * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine * [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list +* [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel +* [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log +* [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets +* [#4196](https://github.com/netbox-community/netbox/issues/4196) - Fix exception when viewing LLDP neighbors page +* [#4202](https://github.com/netbox-community/netbox/issues/4202) - Prevent reassignment to master device when bulk editing VC member interfaces +* [#4204](https://github.com/netbox-community/netbox/issues/4204) - Fix assignment of mask length when bulk editing prefixes +* [#4211](https://github.com/netbox-community/netbox/issues/4211) - Include trailing text when naturalizing interface names +* [#4213](https://github.com/netbox-community/netbox/issues/4213) - Restore display of tags and custom fields on power feed view --- diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index dbd9e6ba1..a425b3ace 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider CIRCUITTYPE_ACTIONS = """ - + {% if perms.circuit.change_circuittype %} diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 234a9fb1c..34d9a7890 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -186,6 +186,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): unit_height = serializers.IntegerField( default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT ) + legend_width = serializers.IntegerField( + default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + ) exclude = serializers.IntegerField( required=False, default=None @@ -194,6 +197,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): required=False, default=True ) + include_images = serializers.BooleanField( + required=False, + default=True + ) # @@ -220,7 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', 'device_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9e7935293..9b0d15ad9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -193,7 +193,13 @@ class RackViewSet(CustomFieldModelViewSet): if data['render'] == 'svg': # Render and return the elevation as an SVG drawing with the correct content type - drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height']) + drawing = rack.get_elevation_svg( + face=data['face'], + unit_width=data['unit_width'], + unit_height=data['unit_height'], + legend_width=data['legend_width'], + include_images=data['include_images'] + ) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') else: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 6ceefa878..e5b77dbaf 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -195,6 +195,7 @@ class ConsolePortTypeChoices(ChoiceSet): TYPE_DE9 = 'de-9' TYPE_DB25 = 'db-25' + TYPE_RJ11 = 'rj-11' TYPE_RJ12 = 'rj-12' TYPE_RJ45 = 'rj-45' TYPE_USB_A = 'usb-a' @@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet): ('Serial', ( (TYPE_DE9, 'DE-9'), (TYPE_DB25, 'DB-25'), + (TYPE_RJ11, 'RJ-11'), (TYPE_RJ12, 'RJ-12'), (TYPE_RJ45, 'RJ-45'), )), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 13a5052e4..39f30639e 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -9,10 +9,10 @@ from .choices import InterfaceTypeChoices RACK_U_HEIGHT_DEFAULT = 42 +RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 - -RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 -RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 +RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220 +RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py new file mode 100644 index 000000000..a3672f60d --- /dev/null +++ b/netbox/dcim/elevations.py @@ -0,0 +1,204 @@ +import svgwrite + +from django.conf import settings +from django.urls import reverse +from django.utils.http import urlencode + +from utilities.utils import foreground_color +from .choices import DeviceFaceChoices +from .constants import RACK_ELEVATION_BORDER_WIDTH + + +class RackElevationSVG: + """ + Use this class to render a rack elevation as an SVG image. + + :param rack: A NetBox Rack instance + :param include_images: If true, the SVG document will embed front/rear device face images, where available + """ + def __init__(self, rack, include_images=True): + self.rack = rack + self.include_images = include_images + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = drawing.linearGradient( + start=(0, 0), + end=(0, 25), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(45, 0, 0)', + gradientUnits='userSpaceOnUse' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color=color) + gradient.add_stop_color(offset='100%', color=color) + drawing.defs.add(gradient) + + @staticmethod + def _setup_drawing(width, height): + drawing = svgwrite.Drawing(size=(width, height)) + + # add the stylesheet + with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file: + drawing.defs.add(drawing.style(css_file.read())) + + # add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') + RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + + return drawing + + def _draw_device_front(self, drawing, device, start, end, text): + name = str(device) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + color = device.device_role.color + link = drawing.add( + drawing.a( + href=reverse('dcim:device', kwargs={'pk': device.pk}), + target='_top', + fill='black' + ) + ) + link.set_desc('{} โ€” {} ({}U) {} {}'.format( + device.device_role, device.device_type.display_name, + device.device_type.u_height, device.asset_tag or '', device.serial or '' + )) + link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) + hex_color = '#{}'.format(foreground_color(color)) + link.add(drawing.text(str(name), insert=text, fill=hex_color)) + + # Embed front device type image if one exists + if self.include_images and device.device_type.front_image: + url = device.device_type.front_image.url + image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image.fit(scale='slice') + link.add(image) + + def _draw_device_rear(self, drawing, device, start, end, text): + rect = drawing.rect(start, end, class_="slot blocked") + rect.set_desc('{} โ€” {} ({}U) {} {}'.format( + device.device_role, device.device_type.display_name, + device.device_type.u_height, device.asset_tag or '', device.serial or '' + )) + drawing.add(rect) + drawing.add(drawing.text(str(device), insert=text)) + + # Embed rear device type image if one exists + if self.include_images and device.device_type.rear_image: + url = device.device_type.rear_image.url + image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image.fit(scale='slice') + drawing.add(image) + + @staticmethod + def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): + link = drawing.add( + drawing.a( + href='{}?{}'.format( + reverse('dcim:device_add'), + urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_}) + ), + target='_top' + ) + ) + if reservation: + link.set_desc('{} โ€” {} ยท {}'.format( + reservation.description, reservation.user, reservation.created + )) + link.add(drawing.rect(start, end, class_=class_)) + link.add(drawing.text("add device", insert=text, class_='add-device')) + + def merge_elevations(self, face): + elevation = self.rack.get_rack_units(face=face, expand_devices=False) + if face == DeviceFaceChoices.FACE_REAR: + other_face = DeviceFaceChoices.FACE_FRONT + else: + other_face = DeviceFaceChoices.FACE_REAR + other = self.rack.get_rack_units(face=other_face) + + unit_cursor = 0 + for u in elevation: + o = other[unit_cursor] + if not u['device'] and o['device']: + u['device'] = o['device'] + u['height'] = 1 + unit_cursor += u.get('height', 1) + + return elevation + + def render(self, face, unit_width, unit_height, legend_width): + """ + Return an SVG document representing a rack elevation. + """ + drawing = self._setup_drawing( + unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, + unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 + ) + reserved_units = self.rack.get_reserved_units() + + unit_cursor = 0 + for ru in range(0, self.rack.u_height): + start_y = ru * unit_height + position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + drawing.add( + drawing.text(str(unit), position_coordinates, class_="unit") + ) + + for unit in self.merge_elevations(face): + + # Loop through all units in the elevation + device = unit['device'] + height = unit.get('height', 1) + + # Setup drawing coordinates + x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH + y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH + end_y = unit_height * height + start_cordinates = (x_offset, y_offset) + end_cordinates = (unit_width, end_y) + text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) + + # Draw the device + if device and device.face == face: + self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) + elif device and device.device_type.is_full_depth: + self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + else: + # Draw shallow devices, reservations, or empty units + class_ = 'slot' + reservation = reserved_units.get(unit["id"]) + if device: + class_ += ' occupied' + if reservation: + class_ += ' reserved' + self._draw_empty( + drawing, + self.rack, + start_cordinates, + end_cordinates, + text_cordinates, + unit["id"], + face, + class_, + reservation + ) + + unit_cursor += height + + # Wrap the drawing with a border + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = drawing.rect( + insert=(legend_width + border_offset, border_offset), + size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), + class_='rack' + ) + drawing.add(frame) + + return drawing diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ad750404b..2b6ba18da 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, widget=APISelect( api_url="/api/dcim/sites/" ) @@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', - 'tags', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'front_image', 'rear_image', 'comments', 'tags', ] widgets = { 'subdevice_role': StaticSelect2() @@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, + disabled=True, widget=forms.HiddenInput() ) type = forms.ChoiceField( @@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) @@ -3061,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, + disabled=True, widget=forms.HiddenInput() ) type = forms.ChoiceField( @@ -4522,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): class PowerPanelForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, widget=APISelect( api_url="/api/dcim/sites/", filter_for={ diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py deleted file mode 100644 index 502719646..000000000 --- a/netbox/dcim/managers.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db.models import Manager, QuerySet - -from .constants import NONCONNECTABLE_IFACE_TYPES - - -class InterfaceQuerySet(QuerySet): - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES) - - -class InterfaceManager(Manager): - - def get_queryset(self): - return InterfaceQuerySet(self.model, using=self._db) diff --git a/netbox/dcim/migrations/0097_interfacetemplate_type_other.py b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py new file mode 100644 index 000000000..d71b5c655 --- /dev/null +++ b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def interfacetemplate_type_to_slug(apps, schema_editor): + InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate') + InterfaceTemplate.objects.filter(type=32767).update(type='other') + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0096_interface_ordering'), + ] + + operations = [ + # Missed type "other" in the initial migration (see #3967) + migrations.RunPython( + code=interfacetemplate_type_to_slug + ), + ] diff --git a/netbox/dcim/migrations/0098_devicetype_images.py b/netbox/dcim/migrations/0098_devicetype_images.py new file mode 100644 index 000000000..837a2b73a --- /dev/null +++ b/netbox/dcim/migrations/0098_devicetype_images.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.9 on 2020-02-20 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0097_interfacetemplate_type_other'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='front_image', + field=models.ImageField(blank=True, upload_to='devicetype-images'), + ), + migrations.AddField( + model_name='devicetype', + name='rear_image', + field=models.ImageField(blank=True, upload_to='devicetype-images'), + ), + ] diff --git a/netbox/dcim/migrations/0097_mptt_remove_indexes.py b/netbox/dcim/migrations/0099_mptt_remove_indexes.py similarity index 93% rename from netbox/dcim/migrations/0097_mptt_remove_indexes.py rename to netbox/dcim/migrations/0099_mptt_remove_indexes.py index 3011edb53..4b6dc0741 100644 --- a/netbox/dcim/migrations/0097_mptt_remove_indexes.py +++ b/netbox/dcim/migrations/0099_mptt_remove_indexes.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0096_interface_ordering'), + ('dcim', '0098_devicetype_images'), ] operations = [ diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 29afef1f1..5848a6201 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1,7 +1,6 @@ from collections import OrderedDict from itertools import count, groupby -import svgwrite import yaml from django.conf import settings from django.contrib.auth.models import User @@ -13,7 +12,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, F, ProtectedError, Sum from django.urls import reverse -from django.utils.http import urlencode from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField @@ -21,10 +19,11 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField +from dcim.elevations import RackElevationSVG from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel -from utilities.utils import foreground_color, to_meters +from utilities.utils import to_meters from .device_component_templates import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, @@ -350,180 +349,7 @@ class RackRole(ChangeLoggedModel): ) -class RackElevationHelperMixin: - """ - Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of - rack units represented as dictionaries, or an SVG of the elevation. - """ - - @staticmethod - def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( - start=('0', '0%'), - end=('0', '5%'), - spreadMethod='repeat', - id_=id_, - gradientTransform='rotate(45, 0, 0)', - gradientUnits='userSpaceOnUse' - ) - gradient.add_stop_color(offset='0%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color=color) - gradient.add_stop_color(offset='100%', color=color) - drawing.defs.add(gradient) - - @staticmethod - def _setup_drawing(width, height): - drawing = svgwrite.Drawing(size=(width, height)) - - # add the stylesheet - with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file: - drawing.defs.add(drawing.style(css_file.read())) - - # add gradients - RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff') - RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0') - - return drawing - - @staticmethod - def _draw_device_front(drawing, device, start, end, text): - name = str(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - - color = device.device_role.color - link = drawing.add( - drawing.a( - href=reverse('dcim:device', kwargs={'pk': device.pk}), - target='_top', - fill='black' - ) - ) - link.set_desc('{} โ€” {} ({}U) {} {}'.format( - device.device_role, device.device_type.display_name, - device.device_type.u_height, device.asset_tag or '', device.serial or '' - )) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) - - @staticmethod - def _draw_device_rear(drawing, device, start, end, text): - rect = drawing.rect(start, end, class_="slot blocked") - rect.set_desc('{} โ€” {} ({}U) {} {}'.format( - device.device_role, device.device_type.display_name, - device.device_type.u_height, device.asset_tag or '', device.serial or '' - )) - drawing.add(rect) - drawing.add(drawing.text(str(device), insert=text)) - - @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): - link = drawing.add( - drawing.a( - href='{}?{}'.format( - reverse('dcim:device_add'), - urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_}) - ), - target='_top' - ) - ) - if reservation: - link.set_desc('{} โ€” {} ยท {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) - - def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width): - - drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height) - - unit_cursor = 0 - for ru in range(0, self.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2) - unit = ru + 1 if self.desc_units else self.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") - ) - - for unit in elevation: - - # Loop through all units in the elevation - device = unit['device'] - height = unit.get('height', 1) - - # Setup drawing coordinates - start_y = unit_cursor * unit_height - end_y = unit_height * height - start_cordinates = (legend_width, start_y) - end_cordinates = (legend_width + unit_width, end_y) - text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2) - - # Draw the device - if device and device.face == face: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation - ) - - unit_cursor += height - - # Wrap the drawing with a border - drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack')) - - return drawing - - def merge_elevations(self, face): - elevation = self.get_rack_units(face=face, expand_devices=False) - other_face = DeviceFaceChoices.FACE_FRONT if face == DeviceFaceChoices.FACE_REAR else DeviceFaceChoices.FACE_REAR - other = self.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device']: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def get_elevation_svg( - self, - face=DeviceFaceChoices.FACE_FRONT, - unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT - ): - """ - Return an SVG of the rack elevation - - :param face: Enum of [front, rear] representing the desired side of the rack elevation to render - :param width: Width in pixles for the rendered drawing - :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total - height of the elevation - """ - elevation = self.merge_elevations(face) - reserved_units = self.get_reserved_units() - - return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width) - - -class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): +class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -835,6 +661,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): reserved_units[u] = r return reserved_units + def get_elevation_svg( + self, + face=DeviceFaceChoices.FACE_FRONT, + unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, + unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + include_images=True + ): + """ + Return an SVG of the rack elevation + + :param face: Enum of [front, rear] representing the desired side of the rack elevation to render + :param unit_width: Width in pixels for the rendered drawing + :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total + height of the elevation + :param legend_width: Width of the unit legend, in pixels + :param include_images: Embed front/rear device images where available + """ + elevation = RackElevationSVG(self, include_images=include_images) + + return elevation.render(face, unit_width, unit_height, legend_width) + def get_0u_devices(self): return self.devices.filter(position=0) @@ -1025,6 +873,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): help_text='Parent devices house child devices in device bays. Leave blank ' 'if this device type is neither a parent nor a child.' ) + front_image = models.ImageField( + upload_to='devicetype-images', + blank=True + ) + rear_image = models.ImageField( + upload_to='devicetype-images', + blank=True + ) comments = models.TextField( blank=True ) @@ -1056,6 +912,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # Save a copy of u_height for validation in clean() self._original_u_height = self.u_height + # Save references to the original front/rear images + self._original_front_image = self.front_image + self._original_rear_image = self.rear_image + def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) @@ -1175,6 +1035,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 'u_height': "Child device types must be 0U." }) + def save(self, *args, **kwargs): + ret = super().save(*args, **kwargs) + + # Delete any previously uploaded image files that are no longer in use + if self.front_image != self._original_front_image: + self._original_front_image.delete(save=False) + if self.rear_image != self._original_rear_image: + self._original_rear_image.delete(save=False) + + return ret + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + + # Delete any uploaded image files + if self.front_image: + self.front_image.delete(save=False) + if self.rear_image: + self.rear_image.delete(save=False) + @property def display_name(self): return '{} {}'.format(self.manufacturer.name, self.model) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1f67b93f1..bc91dd70c 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -41,7 +41,7 @@ DEVICE_LINK = """ """ REGION_ACTIONS = """ - + {% if perms.dcim.change_region %} @@ -50,7 +50,7 @@ REGION_ACTIONS = """ """ RACKGROUP_ACTIONS = """ - + @@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """ """ RACKROLE_ACTIONS = """ - + {% if perms.dcim.change_rackrole %} @@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """ """ RACKRESERVATION_ACTIONS = """ - + {% if perms.dcim.change_rackreservation %} @@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """ """ MANUFACTURER_ACTIONS = """ - + {% if perms.dcim.change_manufacturer %} @@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """ """ DEVICEROLE_ACTIONS = """ - + {% if perms.dcim.change_devicerole %} @@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """ """ PLATFORM_ACTIONS = """ - + {% if perms.dcim.change_platform %} @@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """ """ VIRTUALCHASSIS_ACTIONS = """ - + {% if perms.dcim.change_virtualchassis %} @@ -795,11 +795,12 @@ class InterfaceTable(BaseTable): class InterfaceDetailTable(DeviceComponentDetailTable): parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) name = tables.LinkColumn() + enabled = BooleanColumn() class Meta(InterfaceTable.Meta): order_by = ('parent', 'name') - fields = ('pk', 'parent', 'name', 'type', 'description', 'cable') - sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable') + fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable') + sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable') class FrontPortTable(BaseTable): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0bb6658a2..91b32bc70 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,6 +31,7 @@ from utilities.views import ( from virtualization.models import VirtualMachine from . import filters, forms, tables from .choices import DeviceFaceChoices +from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -1181,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.connectable().prefetch_related( + interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related( '_connected_interface__device' ) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 58433df25..40606ed8e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -40,10 +40,14 @@ class GraphSerializer(ValidatedModelSerializer): class RenderedGraphSerializer(serializers.ModelSerializer): - embed_url = serializers.SerializerMethodField() - embed_link = serializers.SerializerMethodField() + embed_url = serializers.SerializerMethodField( + read_only=True + ) + embed_link = serializers.SerializerMethodField( + read_only=True + ) type = ContentTypeField( - queryset=ContentType.objects.all() + read_only=True ) class Meta: @@ -62,6 +66,9 @@ class RenderedGraphSerializer(serializers.ModelSerializer): # class ExportTemplateSerializer(ValidatedModelSerializer): + content_type = ContentTypeField( + queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS), + ) template_language = ChoiceField( choices=TemplateLanguageChoices, default=TemplateLanguageChoices.LANGUAGE_JINJA2 diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index dca84dfdc..08c5ed471 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ConfigContext, ObjectChange, Tag, TaggedItem TAG_ACTIONS = """ - + {% if perms.taggit.change_tag %} diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index db2861f46..9d25ce576 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -163,17 +163,17 @@ class ExportTemplateTest(APITestCase): super().setUp() - self.content_type = ContentType.objects.get_for_model(Device) + content_type = ContentType.objects.get_for_model(Device) self.exporttemplate1 = ExportTemplate.objects.create( - content_type=self.content_type, name='Test Export Template 1', + content_type=content_type, name='Test Export Template 1', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ) self.exporttemplate2 = ExportTemplate.objects.create( - content_type=self.content_type, name='Test Export Template 2', + content_type=content_type, name='Test Export Template 2', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ) self.exporttemplate3 = ExportTemplate.objects.create( - content_type=self.content_type, name='Test Export Template 3', + content_type=content_type, name='Test Export Template 3', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ) @@ -194,7 +194,7 @@ class ExportTemplateTest(APITestCase): def test_create_exporttemplate(self): data = { - 'content_type': self.content_type.pk, + 'content_type': 'dcim.device', 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', } @@ -205,7 +205,7 @@ class ExportTemplateTest(APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(ExportTemplate.objects.count(), 4) exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id']) - self.assertEqual(exporttemplate4.content_type_id, data['content_type']) + self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device)) self.assertEqual(exporttemplate4.name, data['name']) self.assertEqual(exporttemplate4.template_code, data['template_code']) @@ -213,17 +213,17 @@ class ExportTemplateTest(APITestCase): data = [ { - 'content_type': self.content_type.pk, + 'content_type': 'dcim.device', 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': self.content_type.pk, + 'content_type': 'dcim.device', 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': self.content_type.pk, + 'content_type': 'dcim.device', 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -241,7 +241,7 @@ class ExportTemplateTest(APITestCase): def test_update_exporttemplate(self): data = { - 'content_type': self.content_type.pk, + 'content_type': 'dcim.device', 'name': 'Test Export Template X', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', } diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3912c602f..b625cf7b1 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -12,6 +12,7 @@ from django_tables2 import RequestConfig from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator +from utilities.utils import shallow_compare_dict from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters, forms from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem @@ -207,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View): orderable=False ) + objectchanges = ObjectChange.objects.filter( + changed_object_type=objectchange.changed_object_type, + changed_object_id=objectchange.changed_object_id, + ) + + next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first() + prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first() + + if prev_change: + diff_added = shallow_compare_dict( + prev_change.object_data, + objectchange.object_data, + exclude=['last_updated'], + ) + diff_removed = {x: prev_change.object_data.get(x) for x in diff_added} + else: + # No previous change; this is the initial change that added the object + diff_added = diff_removed = objectchange.object_data + return render(request, 'extras/objectchange.html', { 'objectchange': objectchange, + 'diff_added': diff_added, + 'diff_removed': diff_removed, + 'next_change': next_change, + 'prev_change': prev_change, 'related_changes_table': related_changes_table, 'related_changes_count': related_changes.count() }) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 306e1e925..8d9071dee 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -164,9 +164,19 @@ class NetFamily(Transform): class NetMaskLength(Transform): - lookup_name = 'net_mask_length' function = 'MASKLEN' + lookup_name = 'net_mask_length' @property def output_field(self): return IntegerField() + + +class Host(Transform): + function = 'HOST' + lookup_name = 'host' + + +class Inet(Transform): + function = 'INET' + lookup_name = 'inet' diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py index 47dd08251..8811e504a 100644 --- a/netbox/ipam/managers.py +++ b/netbox/ipam/managers.py @@ -1,5 +1,6 @@ from django.db import models -from django.db.models.expressions import RawSQL + +from ipam.lookups import Host, Inet class IPAddressManager(models.Manager): @@ -13,4 +14,4 @@ class IPAddressManager(models.Manager): IP address as a /32 or /128. """ qs = super().get_queryset() - return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('host') + return qs.order_by(Inet(Host('address'))) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ad119a907..8f059c652 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -26,7 +26,7 @@ RIR_UTILIZATION = """ """ RIR_ACTIONS = """ - + {% if perms.ipam.change_rir %} @@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """ """ ROLE_ACTIONS = """ - + {% if perms.ipam.change_role %} @@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """ """ VLANGROUP_ACTIONS = """ - + {% with next_vid=record.get_next_available_vid %} diff --git a/netbox/media/devicetype-images/.gitignore b/netbox/media/devicetype-images/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/netbox/media/devicetype-images/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 456eeab6f..ec45f43e5 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -179,25 +179,9 @@ nav ul.pagination { /* Racks */ div.rack_header { - margin-left: 36px; + margin-left: 32px; text-align: center; - width: 230px; -} -ul.rack_legend { - float: left; - list-style-type: none; - margin-right: 6px; - padding: 0; - width: 30px; -} -ul.rack_legend li { - color: #c0c0c0; - display: block; - font-size: 10px; - height: 20px; - overflow: hidden; - padding: 5px 0; - text-align: right; + width: 220px; } /* Devices */ diff --git a/netbox/project-static/css/rack_elevation.css b/netbox/project-static/css/rack_elevation.css index cbb5015a5..6c04855a3 100644 --- a/netbox/project-static/css/rack_elevation.css +++ b/netbox/project-static/css/rack_elevation.css @@ -14,7 +14,7 @@ text { background-color: #f0f0f0; fill: none; stroke: black; - stroke-width: 3px; + stroke-width: 2px; } .slot { fill: #f7f7f7; @@ -56,7 +56,6 @@ text { .blocked:hover+.add-device { fill: none; } - .unit { margin: 0; padding: 5px 0px; @@ -65,3 +64,6 @@ text { font-size: 10px; font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; } +.hidden { + visibility: hidden; +} diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 802d1b4e9..ff520a23f 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -42,17 +42,23 @@ $(document).ready(function() { return s.substring(0, num_chars); // Trim to first num_chars chars } var slug_field = $('#id_slug'); - slug_field.change(function() { - $(this).attr('_changed', true); - }); if (slug_field) { var slug_source = $('#id_' + slug_field.attr('slug-source')); var slug_length = slug_field.attr('maxlength'); + if (slug_field.val()) { + slug_field.attr('_changed', true); + } + slug_field.change(function() { + $(this).attr('_changed', true); + }); slug_source.on('keyup change', function() { if (slug_field && !slug_field.attr('_changed')) { slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50))); } - }) + }); + $('button.reslugify').click(function() { + slug_field.val(slugify(slug_source.val(), (slug_length ? slug_length : 50))); + }); } // Bulk edit nullification diff --git a/netbox/project-static/js/rack_elevations.js b/netbox/project-static/js/rack_elevations.js new file mode 100644 index 000000000..138065e3c --- /dev/null +++ b/netbox/project-static/js/rack_elevations.js @@ -0,0 +1,16 @@ +// Toggle the display of device images within an SVG rack elevation +$('button.toggle-images').click(function() { + var selected = $(this).attr('selected'); + var rack_front = $("#rack_front"); + var rack_rear = $("#rack_rear"); + if (selected) { + $('.device-image', rack_front.contents()).addClass('hidden'); + $('.device-image', rack_rear.contents()).addClass('hidden'); + } else { + $('.device-image', rack_front.contents()).removeClass('hidden'); + $('.device-image', rack_rear.contents()).removeClass('hidden'); + } + $(this).attr('selected', !selected); + $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); + return false; +}); diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 79064e0dd..88e5325ec 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', - required=True, + required=False, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 2b76e0f5a..7cebb744c 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -302,8 +302,8 @@ class Secret(ChangeLoggedModel, CustomFieldModel): Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the ciphertext; this string is stored as plain text in the database. - A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum - of 64 bytes during encryption in order to protect short strings from ciphertext analysis. + A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to + a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ device = models.ForeignKey( to='dcim.Device', @@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel): blank=True ) ciphertext = models.BinaryField( - max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded + max_length=65568, # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding editable=False ) hash = models.CharField( @@ -388,11 +388,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel): else: pad_length = 0 - # Python 2 compatibility - if sys.version_info[0] < 3: - header = chr(len(s) >> 8) + chr(len(s) % 256) - else: - header = bytes([len(s) >> 8]) + bytes([len(s) % 256]) + header = bytes([len(s) >> 8]) + bytes([len(s) % 256]) return header + s + os.urandom(pad_length) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index cc1760b93..1e8a4e669 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import SecretRole, Secret SECRETROLE_ACTIONS = """ - + {% if perms.secrets.change_secretrole %} diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py index 80bc953bc..884df9d14 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase): class SecretTestCase(TestCase): + @classmethod + def setUpTestData(cls): + + # Generate a random key for encryption/decryption of secrets + cls.secret_key = generate_random_key() + def test_01_encrypt_decrypt(self): """ Test basic encryption and decryption functionality using a random master key. """ plaintext = string.printable * 2 - secret_key = generate_random_key() s = Secret(plaintext=plaintext) - s.encrypt(secret_key) + s.encrypt(self.secret_key) # Ensure plaintext is deleted upon encryption self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.") @@ -112,7 +117,7 @@ class SecretTestCase(TestCase): self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash") # Test decryption - s.decrypt(secret_key) + s.decrypt(self.secret_key) self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext") def test_02_ciphertext_uniqueness(self): @@ -120,15 +125,45 @@ class SecretTestCase(TestCase): Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads. """ plaintext = "1234567890abcdef" - secret_key = generate_random_key() ivs = [] ciphertexts = [] for i in range(1, 51): s = Secret(plaintext=plaintext) - s.encrypt(secret_key) + s.encrypt(self.secret_key) ivs.append(s.ciphertext[0:16]) ciphertexts.append(s.ciphertext[16:32]) duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1] self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!") duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1] self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!") + + def test_minimum_length(self): + """ + Test enforcement of the minimum length for ciphertexts. + """ + plaintext = 'A' # One-byte plaintext + secret = Secret(plaintext=plaintext) + secret.encrypt(self.secret_key) + + # 16B IV + 2B length + 1B secret + 61B padding = 80 bytes + self.assertEqual(len(secret.ciphertext), 80) + self.assertIsNone(secret.plaintext) + + secret.decrypt(self.secret_key) + self.assertEqual(secret.plaintext, plaintext) + + def test_maximum_length(self): + """ + Test encrypting a plaintext value of the maximum length. + """ + plaintext = '0123456789abcdef' * 4096 + plaintext = plaintext[:65535] # 65,535 chars + secret = Secret(plaintext=plaintext) + secret.encrypt(self.secret_key) + + # 16B IV + 2B length + 65535B secret + 15B padding = 65568 bytes + self.assertEqual(len(secret.ciphertext), 65568) + self.assertIsNone(secret.plaintext) + + secret.decrypt(self.secret_key) + self.assertEqual(secret.plaintext, plaintext) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 712ee5861..07d3c360d 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 5bf05b0e2..e99f8b2c4 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -54,7 +54,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index a190a7001..a78879b23 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -31,7 +31,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 5ede19d78..8c457121f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -119,7 +119,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 68384f12b..292a31c89 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -54,7 +54,7 @@ {% if perms.extras.view_objectchange %} {% endif %} @@ -109,6 +109,30 @@ {% endif %} + + Front Image + + {% if devicetype.front_image %} + + {{ devicetype.front_image.name }} + + {% else %} + + {% endif %} + + + + Rear Image + + {% if devicetype.rear_image %} + + {{ devicetype.rear_image.name }} + + {% else %} + + {% endif %} + + Instances {{ devicetype.instances.count }} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index 3c22eb9cd..f4f363b14 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -14,6 +14,13 @@ {% render_field form.subdevice_role %} +
+
Rack Images
+
+ {% render_field form.front_image %} + {% render_field form.rear_image %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index b0fcf4908..e020c44d6 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,7 +1,6 @@ -{% load helpers %} - -
- - - + + diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 6027afb58..4fc7d21ac 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -34,7 +34,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 3ce1d36f2..316e0e5f0 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -52,7 +52,7 @@ {% if perms.extras.view_objectchange %} {% endif %} @@ -121,18 +121,8 @@
-
-
- Comments -
-
- {% if powerfeed.comments %} - {{ powerfeed.comments|gfm }} - {% else %} - None - {% endif %} -
-
+ {% include 'inc/custom_fields_panel.html' with obj=powerfeed %} + {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
@@ -162,6 +152,18 @@
+
+
+ Comments +
+
+ {% if powerfeed.comments %} + {{ powerfeed.comments|gfm }} + {% else %} + None + {% endif %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b0a6961f6..6d47e08b1 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -48,7 +48,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51faeef50..b43a4bfdf 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load static %} {% block header %}
@@ -45,6 +46,9 @@

{% block title %}Rack {{ rack }}{% endblock %}

{% include 'inc/created_updated.html' with obj=rack %}
+ {% custom_links rack %}
@@ -368,9 +372,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index da4f002d6..2ff622f0f 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -1,8 +1,12 @@ {% extends '_base.html' %} {% load helpers %} +{% load static %} {% block content %}
+ Front Rear
@@ -41,9 +45,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index decfc9261..dfbf65aeb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -60,7 +60,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index 5d649a692..970b54d4d 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -1,12 +1,12 @@ {% extends base_template %} -{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %} +{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Change Log{% endblock %} {% block content %} {% if obj %}

{{ obj }}

{% endif %} {% include 'panel_table.html' %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
- Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %} + Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index ee29281f9..16efa6421 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -7,7 +7,7 @@
+
+
+ Difference + +
+
+ {% if diff_added == diff_removed %} + + {% if objectchange.action == 'create' %} + Object created + {% elif objectchange.action == 'delete' %} + Object deleted + {% else %} + No changes + {% endif %} + + {% else %} +
{{ diff_removed|render_json }}
+
{{ diff_added|render_json }}
+ {% endif %} +
+
diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index c38bc2f50..2857d6188 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -44,7 +44,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index c4821d6c3..6977bba4c 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -284,7 +284,7 @@
- Changelog + Change Log
{% if changelog and perms.extras.view_objectchange %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index c34380722..43cfb10a0 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -48,7 +48,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 6a3a23bde..9611be175 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -14,7 +14,7 @@ {{ service.description }} - + {% if perms.ipam.change_service %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 167d3fddf..83c34cd6b 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index f14cab259..5d5490937 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -69,7 +69,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 50b964992..246f3c866 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -55,7 +55,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 242cfde92..7bb2dea25 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -46,7 +46,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 438a5a943..6045897c9 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -34,7 +34,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 71c063502..9a1ed1be8 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 6b90a660d..92540fd70 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 10c1f36d4..8f3bb61d4 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -54,7 +54,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 884bdc3df..af4fb34c0 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Tenant, TenantGroup TENANTGROUP_ACTIONS = """ - + {% if perms.tenancy.change_tenantgroup %} diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 6181a7ca1..4eb19f539 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -56,8 +56,11 @@ class NaturalOrderingField(models.CharField): """ Generate a naturalized value from the target field """ - value = getattr(model_instance, self.target_field) - return self.naturalize_function(value, max_length=self.max_length) + original_value = getattr(model_instance, self.target_field) + naturalized_value = self.naturalize_function(original_value, max_length=self.max_length) + setattr(model_instance, self.attname, naturalized_value) + + return naturalized_value def deconstruct(self): kwargs = super().deconstruct()[3] # Pass kwargs from CharField diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c629d70a2..a7ee63eaa 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -132,6 +132,13 @@ class SmallTextarea(forms.Textarea): pass +class SlugWidget(forms.TextInput): + """ + Subclass TextInput and add a slug regeneration button next to the form field. + """ + template_name = 'widgets/sluginput.html' + + class ColorSelect(forms.Select): """ Extends the built-in Select widget to colorize each