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/base_requirements.txt b/base_requirements.txt index 8b42c835d..ed42b6c08 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -22,6 +22,10 @@ django-filter # https://github.com/django-mptt/django-mptt django-mptt +# Context managers for PostgreSQL advisory locks +# https://github.com/Xof/django-pglocks +django-pglocks + # Prometheus metrics library for Django # https://github.com/korfuri/django-prometheus django-prometheus 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/configuration/required-settings.md b/docs/configuration/required-settings.md index 81790eae0..e86b2810a 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -80,11 +80,11 @@ REDIS = { } ``` -!!! note: +!!! note If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary -!!! warning: +!!! note It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the same Redis instance for both may result in webhook processing data being lost during cache flushing events. @@ -124,7 +124,7 @@ REDIS = { } ``` -!!! note: +!!! note It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via `SENTINELS`/`SENTINEL_SERVICE`. diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 70981658f..95cdb70dd 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,39 @@ +# 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 + +--- + # v2.7.6 (2020-02-13) ## Bug Fixes 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/circuits/views.py b/netbox/circuits/views.py index 15cf901c1..ba873f23f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderDetailTable - template_name = 'circuits/provider_list.html' class ProviderView(PermissionRequiredMixin, View): @@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): permission_required = 'circuits.view_circuittype' queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable - template_name = 'circuits/circuittype_list.html' class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): @@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable - template_name = 'circuits/circuit_list.html' class CircuitView(PermissionRequiredMixin, View): 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 8bb127f67..f8297fe46 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -220,7 +220,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/elevations.py b/netbox/dcim/elevations.py new file mode 100644 index 000000000..ed4a3d10a --- /dev/null +++ b/netbox/dcim/elevations.py @@ -0,0 +1,192 @@ +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 + + +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', '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 + 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.stretch() + 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.front_image: + url = device.device_type.rear_image.url + image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image.stretch() + 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, unit_height * self.rack.u_height) + 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 + 2) + 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 + 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.rack, + 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, 1), (unit_width - 1, self.rack.u_height * unit_height - 2), class_='rack')) + + return drawing diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8f035ccbb..0e5fb382a 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) @@ -2832,7 +2838,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -2842,7 +2851,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tags = TagField( @@ -2871,18 +2883,20 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or VC master) if self.is_bound: device = Device.objects.get(pk=self.data['device']) - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) else: - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.instance.device, self.instance.device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) + device = self.instance.device + + # Limit LAG choices to interfaces belonging to this device (or VC master) + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + type=InterfaceTypeChoices.TYPE_LAG + ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): @@ -2942,7 +2956,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -2951,7 +2968,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -2967,6 +2987,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): type=InterfaceTypeChoices.TYPE_LAG ) + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) + class InterfaceCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( @@ -3043,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( @@ -3090,7 +3115,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -3099,7 +3127,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -3118,6 +3149,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) else: self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True @@ -4494,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/models/__init__.py b/netbox/dcim/models/__init__.py index f291fc825..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', '#f0f0f0') - RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7') - - 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 ae59890a3..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, @@ -152,7 +153,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RegionFilterSet filterset_form = forms.RegionFilterForm table = tables.RegionTable - template_name = 'dcim/region_list.html' class RegionCreateView(PermissionRequiredMixin, ObjectEditView): @@ -191,7 +191,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SiteFilterSet filterset_form = forms.SiteFilterForm table = tables.SiteTable - template_name = 'dcim/site_list.html' class SiteView(PermissionRequiredMixin, View): @@ -271,7 +270,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackGroupFilterSet filterset_form = forms.RackGroupFilterForm table = tables.RackGroupTable - template_name = 'dcim/rackgroup_list.html' class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -308,7 +306,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rackrole' queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable - template_name = 'dcim/rackrole_list.html' class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -350,7 +347,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackFilterSet filterset_form = forms.RackFilterForm table = tables.RackDetailTable - template_name = 'dcim/rack_list.html' class RackElevationListView(PermissionRequiredMixin, View): @@ -474,7 +470,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable - template_name = 'dcim/rackreservation_list.html' + action_buttons = () class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): @@ -533,7 +529,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView): platform_count=Count('platforms', distinct=True), ) table = tables.ManufacturerTable - template_name = 'dcim/manufacturer_list.html' class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): @@ -571,7 +566,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable - template_name = 'dcim/devicetype_list.html' class DeviceTypeView(PermissionRequiredMixin, View): @@ -995,7 +989,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_devicerole' queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable - template_name = 'dcim/devicerole_list.html' class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -1031,7 +1024,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_platform' queryset = Platform.objects.all() table = tables.PlatformTable - template_name = 'dcim/platform_list.html' class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): @@ -1190,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' ) @@ -1292,7 +1284,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortDetailTable - template_name = 'dcim/consoleport_list.html' + action_buttons = ('import', 'export') class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1345,7 +1337,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortDetailTable - template_name = 'dcim/consoleserverport_list.html' + action_buttons = ('import', 'export') class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1410,7 +1402,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortDetailTable - template_name = 'dcim/powerport_list.html' + action_buttons = ('import', 'export') class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1463,7 +1455,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletDetailTable - template_name = 'dcim/poweroutlet_list.html' + action_buttons = ('import', 'export') class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1528,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceDetailTable - template_name = 'dcim/interface_list.html' + action_buttons = ('import', 'export') class InterfaceView(PermissionRequiredMixin, View): @@ -1630,7 +1622,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortDetailTable - template_name = 'dcim/frontport_list.html' + action_buttons = ('import', 'export') class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1695,7 +1687,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortDetailTable - template_name = 'dcim/rearport_list.html' + action_buttons = ('import', 'export') class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1762,7 +1754,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayDetailTable - template_name = 'dcim/devicebay_list.html' + action_buttons = ('import', 'export') class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1961,7 +1953,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable - template_name = 'dcim/cable_list.html' + action_buttons = ('import', 'export') class CableView(PermissionRequiredMixin, View): @@ -2233,7 +2225,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_list.html' + action_buttons = ('import', 'export') class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): @@ -2289,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm - template_name = 'dcim/virtualchassis_list.html' + action_buttons = ('export',) class VirtualChassisCreateView(PermissionRequiredMixin, View): @@ -2533,7 +2525,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPanelFilterSet filterset_form = forms.PowerPanelFilterForm table = tables.PowerPanelTable - template_name = 'dcim/powerpanel_list.html' class PowerPanelView(PermissionRequiredMixin, View): @@ -2602,7 +2593,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerFeedFilterSet filterset_form = forms.PowerFeedFilterForm table = tables.PowerFeedTable - template_name = 'dcim/powerfeed_list.html' class PowerFeedView(PermissionRequiredMixin, View): diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index f8c5a98e6..3201c3bb2 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,28 +1,8 @@ from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -import redis class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.signals - - # Check that we can connect to the configured Redis database. - try: - rs = redis.Redis( - host=settings.WEBHOOKS_REDIS_HOST, - port=settings.WEBHOOKS_REDIS_PORT, - db=settings.WEBHOOKS_REDIS_DATABASE, - password=settings.WEBHOOKS_REDIS_PASSWORD or None, - ssl=settings.WEBHOOKS_REDIS_SSL, - ) - rs.ping() - except redis.exceptions.ConnectionError: - raise ImproperlyConfigured( - "Unable to connect to the Redis database. Check that the Redis configuration has been defined in " - "configuration.py." - ) 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/views.py b/netbox/extras/views.py index 73d29393f..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 @@ -34,7 +35,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView): filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm table = TagTable - template_name = 'extras/tag_list.html' + action_buttons = () class TagView(PermissionRequiredMixin, View): @@ -111,7 +112,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = ConfigContextTable - template_name = 'extras/configcontext_list.html' + action_buttons = ('add',) class ConfigContextView(PermissionRequiredMixin, View): @@ -191,6 +192,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = ObjectChangeTable template_name = 'extras/objectchange_list.html' + action_buttons = ('export',) class ObjectChangeView(PermissionRequiredMixin, View): @@ -206,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/api/views.py b/netbox/ipam/api/views.py index 08e21367c..262ca7908 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db.models import Count from django.shortcuts import get_object_or_404 +from django_pglocks import advisory_lock from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers @@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet): filterset_class = filters.PrefixFilterSet @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def available_prefixes(self, request, pk=None): """ A convenience method for returning available child prefixes within a parent. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) available_prefixes = prefix.get_available_prefixes() @@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) @action(detail=True, url_path='available-ips', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): """ A convenience method for returning available IP addresses within a prefix. By default, the number of IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, however results will not be paginated. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 62b356c5d..92fa5780d 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -154,10 +154,24 @@ class NetHostContained(Lookup): return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params +# +# Transforms +# + 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 8aebc60ce..8279bf205 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('family', 'host') + return qs.order_by('family', 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/ipam/views.py b/netbox/ipam/views.py index c8c7d40ca..053098f0b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VRFFilterSet filterset_form = forms.VRFFilterForm table = tables.VRFTable - template_name = 'ipam/vrf_list.html' class VRFView(PermissionRequiredMixin, View): @@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView): queryset = Aggregate.objects.prefetch_related('rir').annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - filterset = filters.AggregateFilterSet filterset_form = forms.AggregateFilterForm table = tables.AggregateDetailTable @@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'ipam.view_role' queryset = Role.objects.all() table = tables.RoleTable - template_name = 'ipam/role_list.html' class RoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView): filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm table = tables.IPAddressDetailTable - template_name = 'ipam/ipaddress_list.html' class IPAddressView(PermissionRequiredMixin, View): @@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable - template_name = 'ipam/vlangroup_list.html' class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANFilterSet filterset_form = forms.VLANFilterForm table = tables.VLANDetailTable - template_name = 'ipam/vlan_list.html' class VLANView(PermissionRequiredMixin, View): @@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable - template_name = 'ipam/service_list.html' + action_buttons = ('export',) class ServiceView(PermissionRequiredMixin, View): 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/netbox/settings.py b/netbox/netbox/settings.py index 81422b714..6c33e5b54 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.6' +VERSION = '2.7.7' # Hostname HOSTNAME = platform.node() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 456eeab6f..281e5d9b7 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -179,26 +179,10 @@ nav ul.pagination { /* Racks */ div.rack_header { - margin-left: 36px; + margin-left: 30px; 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; -} /* Devices */ table.component-list td.subtable { 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 4e1c9b0cc..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 @@ -190,15 +196,18 @@ $(document).ready(function() { $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-additional-query-param-")){ var param_name = attr.name.split("data-additional-query-param-")[1]; - if (param_name in parameters) { - if (Array.isArray(parameters[param_name])) { - parameters[param_name].push(attr.value) + + $.each($.parseJSON(attr.value), function(index, value) { + if (param_name in parameters) { + if (Array.isArray(parameters[param_name])) { + parameters[param_name].push(value); + } else { + parameters[param_name] = [parameters[param_name], value]; + } } else { - parameters[param_name] = [parameters[param_name], attr.value] + parameters[param_name] = value; } - } else { - parameters[param_name] = attr.value; - } + }); } }); 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/secrets/views.py b/netbox/secrets/views.py index 288edaa6f..d92e4b64d 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'secrets.view_secretrole' queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable - template_name = 'secrets/secretrole_list.html' class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm table = tables.SecretTable - template_name = 'secrets/secret_list.html' + action_buttons = ('import', 'export') class SecretView(PermissionRequiredMixin, View): 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/circuit_list.html b/netbox/templates/circuits/circuit_list.html deleted file mode 100644 index 169aab072..000000000 --- a/netbox/templates/circuits/circuit_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_circuit %} - {% add_button 'circuits:circuit_add' %} - {% import_button 'circuits:circuit_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Circuits{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html deleted file mode 100644 index 654d4ab09..000000000 --- a/netbox/templates/circuits/circuittype_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_circuittype %} - {% add_button 'circuits:circuittype_add' %} - {% import_button 'circuits:circuittype_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Circuit Types{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %} -
-
-{% endblock %} 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/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html deleted file mode 100644 index 4126f75ec..000000000 --- a/netbox/templates/circuits/provider_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.circuits.add_provider %} - {% add_button 'circuits:provider_add' %} - {% import_button 'circuits:provider_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Providers{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/cable_list.html b/netbox/templates/dcim/cable_list.html deleted file mode 100644 index 0dd8095a5..000000000 --- a/netbox/templates/dcim/cable_list.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_cable %} - {% import_button 'dcim:cable_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Cables{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/consoleport_list.html b/netbox/templates/dcim/consoleport_list.html deleted file mode 100644 index 0ed840820..000000000 --- a/netbox/templates/dcim/consoleport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Console Ports{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_list.html b/netbox/templates/dcim/consoleserverport_list.html deleted file mode 100644 index 47a8676e3..000000000 --- a/netbox/templates/dcim/consoleserverport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Console Server Ports{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/device_list.html b/netbox/templates/dcim/device_list.html index 8b991689f..b12e4b5a8 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,21 +1,24 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
- {% if perms.dcim.add_device %} - {% add_button 'dcim:device_add' %} - {% import_button 'dcim:device_import' %} +{% block bulk_buttons %} + {% if perms.dcim.change_device %} +
+ + +
+ {% endif %} + {% if perms.dcim.add_virtualchassis %} + {% endif %} - {% export_button content_type %} -
-

{% block title %}Devices{% endblock %}

-
-
- {% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
{% endblock %} diff --git a/netbox/templates/dcim/devicebay_list.html b/netbox/templates/dcim/devicebay_list.html deleted file mode 100644 index 74f64858a..000000000 --- a/netbox/templates/dcim/devicebay_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Device Bays{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/devicerole_list.html b/netbox/templates/dcim/devicerole_list.html deleted file mode 100644 index 9f560dab4..000000000 --- a/netbox/templates/dcim/devicerole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_devicerole %} - {% add_button 'dcim:devicerole_add' %} - {% import_button 'dcim:devicerole_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Device Roles{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicerole_bulk_delete' %} -
-
-{% endblock %} 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/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html deleted file mode 100644 index 75f587f5d..000000000 --- a/netbox/templates/dcim/devicetype_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_devicetype %} - {% add_button 'dcim:devicetype_add' %} - {% import_button 'dcim:devicetype_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Device Types{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/frontport_list.html b/netbox/templates/dcim/frontport_list.html deleted file mode 100644 index a3334b876..000000000 --- a/netbox/templates/dcim/frontport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Front Ports{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html deleted file mode 100644 index 68570fdf3..000000000 --- a/netbox/templates/dcim/inc/device_table.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends 'utilities/obj_table.html' %} - -{% block extra_actions %} - {% if perms.dcim.change_device %} -
- - -
- {% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index b0fcf4908..feced6a22 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,7 +1,4 @@ {% 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/interface_list.html b/netbox/templates/dcim/interface_list.html deleted file mode 100644 index 9dd8f7858..000000000 --- a/netbox/templates/dcim/interface_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Interfaces{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html deleted file mode 100644 index 57e7d2d03..000000000 --- a/netbox/templates/dcim/inventoryitem_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load helpers %} - -{% block content %} -
- {% if perms.dcim.add_devicetype %} - {% import_button 'dcim:inventoryitem_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Inventory Items{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html deleted file mode 100644 index 9b612dfa5..000000000 --- a/netbox/templates/dcim/manufacturer_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_manufacturer %} - {% add_button 'dcim:manufacturer_add' %} - {% import_button 'dcim:manufacturer_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Manufacturers{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:manufacturer_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/platform_list.html b/netbox/templates/dcim/platform_list.html deleted file mode 100644 index d82ef9a44..000000000 --- a/netbox/templates/dcim/platform_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_platform %} - {% add_button 'dcim:platform_add' %} - {% import_button 'dcim:platform_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Platforms{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:platform_bulk_delete' %} -
-
-{% endblock %} 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/powerfeed_list.html b/netbox/templates/dcim/powerfeed_list.html deleted file mode 100644 index e384cb2c2..000000000 --- a/netbox/templates/dcim/powerfeed_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_powerfeed %} - {% add_button 'dcim:powerfeed_add' %} - {% import_button 'dcim:powerfeed_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Power Feeds{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_list.html b/netbox/templates/dcim/poweroutlet_list.html deleted file mode 100644 index 2e842d699..000000000 --- a/netbox/templates/dcim/poweroutlet_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Power Outlets{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% 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/powerpanel_list.html b/netbox/templates/dcim/powerpanel_list.html deleted file mode 100644 index a0d49b30b..000000000 --- a/netbox/templates/dcim/powerpanel_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_powerpanel %} - {% add_button 'dcim:powerpanel_add' %} - {% import_button 'dcim:powerpanel_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Power Panels{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/powerport_list.html b/netbox/templates/dcim/powerport_list.html deleted file mode 100644 index b5830edca..000000000 --- a/netbox/templates/dcim/powerport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Power Ports{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/rack_list.html b/netbox/templates/dcim/rack_list.html deleted file mode 100644 index 2724e4427..000000000 --- a/netbox/templates/dcim/rack_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_rack %} - {% add_button 'dcim:rack_add' %} - {% import_button 'dcim:rack_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Racks{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html deleted file mode 100644 index 52723ef92..000000000 --- a/netbox/templates/dcim/rackgroup_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_rackgroup %} - {% add_button 'dcim:rackgroup_add' %} - {% import_button 'dcim:rackgroup_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Rack Groups{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rackreservation_list.html b/netbox/templates/dcim/rackreservation_list.html deleted file mode 100644 index b5424bbe6..000000000 --- a/netbox/templates/dcim/rackreservation_list.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends '_base.html' %} -{% load helpers %} - -{% block content %} -

{% block title %}Rack Reservations{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rackrole_list.html b/netbox/templates/dcim/rackrole_list.html deleted file mode 100644 index 267ef3c7f..000000000 --- a/netbox/templates/dcim/rackrole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_rackrole %} - {% add_button 'dcim:rackrole_add' %} - {% import_button 'dcim:rackrole_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Rack Roles{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rearport_list.html b/netbox/templates/dcim/rearport_list.html deleted file mode 100644 index cc603d620..000000000 --- a/netbox/templates/dcim/rearport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Rear Ports{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html deleted file mode 100644 index ec1adfc06..000000000 --- a/netbox/templates/dcim/region_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_region %} - {% add_button 'dcim:region_add' %} - {% import_button 'dcim:region_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Regions{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% 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/dcim/site_list.html b/netbox/templates/dcim/site_list.html deleted file mode 100644 index ef9e0e411..000000000 --- a/netbox/templates/dcim/site_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.dcim.add_site %} - {% add_button 'dcim:site_add' %} - {% import_button 'dcim:site_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Sites{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html deleted file mode 100644 index 55cfc1691..000000000 --- a/netbox/templates/dcim/virtualchassis_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load helpers %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Virtual Chassis{% endblock %}

-
-
- {% include 'utilities/obj_table.html' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html deleted file mode 100644 index f21be2836..000000000 --- a/netbox/templates/extras/configcontext_list.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.extras.add_configcontext %} - {% add_button 'extras:configcontext_add' %} - {% endif %} -
-

{% block title %}Config Contexts{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/objectchange_list.html b/netbox/templates/extras/objectchange_list.html index e9be6ba69..3672f4f04 100644 --- a/netbox/templates/extras/objectchange_list.html +++ b/netbox/templates/extras/objectchange_list.html @@ -1,20 +1,9 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Changelog{% endblock %}

-
-
- {% include 'utilities/obj_table.html' %} -
- Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %} -
+{% block title %}Change Log{% endblock %} + +{% block sidebar %} +
+ Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
-
- {% include 'inc/search_panel.html' %} -
-
{% endblock %} 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/extras/tag_list.html b/netbox/templates/extras/tag_list.html deleted file mode 100644 index c87b6c2e5..000000000 --- a/netbox/templates/extras/tag_list.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -

{% block title %}Tags{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 55ac3e5c2..900d783f6 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -473,7 +473,7 @@ diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 27363a56d..85a2bd36d 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,31 +1,14 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} {% load humanize %} -{% block content %} -
- {% if perms.ipam.add_aggregate %} - {% add_button 'ipam:aggregate_add' %} - {% import_button 'ipam:aggregate_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Aggregates{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
- Statistics -
-
    -
  • Total IPv4 IPs {{ ipv4_total|intcomma }}
  • -
  • Total IPv6 /64s {{ ipv6_total|intcomma }}
  • -
+{% block sidebar %} +
+
+ Statistics
-
-
+
    +
  • Total IPv4 IPs {{ ipv4_total|intcomma }}
  • +
  • Total IPv6 /64s {{ ipv6_total|intcomma }}
  • +
+
{% endblock %} 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 50bd90610..08a311492 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/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html deleted file mode 100644 index b7920a434..000000000 --- a/netbox/templates/ipam/ipaddress_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.ipam.add_ipaddress %} - {% add_button 'ipam:ipaddress_add' %} - {% import_button 'ipam:ipaddress_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}IP Addresses{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 324bd927d..7bfa7ba1f 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/prefix_list.html b/netbox/templates/ipam/prefix_list.html index f0754d37b..00f0b7fe9 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,26 +1,9 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} {% load helpers %} -{% block content %} -
+{% block buttons %} - {% if perms.ipam.add_prefix %} - {% add_button 'ipam:prefix_add' %} - {% import_button 'ipam:prefix_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Prefixes{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
{% endblock %} diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index 846eb3cb2..02f01fc7c 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,9 +1,6 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load humanize %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
+{% block buttons %} {% if request.GET.family == '6' %} @@ -15,22 +12,12 @@ IPv6 Stats {% endif %} - {% if perms.ipam.add_rir %} - {% add_button 'ipam:rir_add' %} - {% import_button 'ipam:rir_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}RIRs{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %} - {% if request.GET.family == '6' %} -
Note: Numbers shown indicate /64 prefixes.
- {% endif %} -
-
- {% include 'inc/search_panel.html' %} -
-
+{% endblock %} + +{% block sidebar %} + {% if request.GET.family == '6' %} +
+ Numbers shown indicate /64 prefixes. +
+ {% endif %} {% endblock %} diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html deleted file mode 100644 index 958fa8e06..000000000 --- a/netbox/templates/ipam/role_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.ipam.add_role %} - {% add_button 'ipam:role_add' %} - {% import_button 'ipam:role_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Prefix/VLAN Roles{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:role_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html deleted file mode 100644 index 4aac520d9..000000000 --- a/netbox/templates/ipam/service_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% export_button content_type %} -
-

{% block title %}Services{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/vlan_list.html b/netbox/templates/ipam/vlan_list.html deleted file mode 100644 index 24d538f88..000000000 --- a/netbox/templates/ipam/vlan_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.ipam.add_vlan %} - {% add_button 'ipam:vlan_add' %} - {% import_button 'ipam:vlan_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}VLANs{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html deleted file mode 100644 index 16ddd9669..000000000 --- a/netbox/templates/ipam/vlangroup_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.ipam.add_vlangroup %} - {% add_button 'ipam:vlangroup_add' %} - {% import_button 'ipam:vlangroup_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}VLAN Groups{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html deleted file mode 100644 index 975c73a37..000000000 --- a/netbox/templates/ipam/vrf_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.ipam.add_vrf %} - {% add_button 'ipam:vrf_add' %} - {% import_button 'ipam:vrf_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}VRFs{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vrf_bulk_edit' bulk_delete_url='ipam:vrf_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} 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/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html deleted file mode 100644 index ee631b439..000000000 --- a/netbox/templates/secrets/secret_list.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.secrets.add_secret %} - {% import_button 'secrets:secret_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Secrets{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='secrets:secret_bulk_edit' bulk_delete_url='secrets:secret_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html deleted file mode 100644 index 0e4caadae..000000000 --- a/netbox/templates/secrets/secretrole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.secrets.add_secretrole %} - {% add_button 'secrets:secretrole_add' %} - {% import_button 'secrets:secretrole_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Secret Roles{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='secrets:secretrole_bulk_delete' %} -
-
-{% endblock %} 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/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html deleted file mode 100644 index a77636a5b..000000000 --- a/netbox/templates/tenancy/tenant_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.tenancy.add_tenant %} - {% add_button 'tenancy:tenant_add' %} - {% import_button 'tenancy:tenant_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Tenants{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html deleted file mode 100644 index af0dc1aad..000000000 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.tenancy.add_tenantgroup %} - {% add_button 'tenancy:tenantgroup_add' %} - {% import_button 'tenancy:tenantgroup_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Tenant Groups{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html new file mode 100644 index 000000000..fe70edd3b --- /dev/null +++ b/netbox/templates/utilities/obj_list.html @@ -0,0 +1,79 @@ +{% extends '_base.html' %} +{% load buttons %} +{% load helpers %} + +{% block content %} +
+ {% block buttons %}{% endblock %} + {% if permissions.add and 'add' in action_buttons %} + {% add_button content_type.model_class|url_name:"add" %} + {% endif %} + {% if permissions.add and 'import' in action_buttons %} + {% import_button content_type.model_class|url_name:"import" %} + {% endif %} + {% if 'export' in action_buttons %} + {% export_button content_type %} + {% endif %} +
+

{% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

+
+
+ {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% if permissions.change or permissions.delete %} +
+ {% csrf_token %} + + {% if table.paginator.num_pages > 1 %} + + {% endif %} + {% include table_template|default:'responsive_table.html' %} +
+ {% block bulk_buttons %}{% endblock %} + {% if bulk_edit_url and permissions.change %} + + {% endif %} + {% if bulk_delete_url and permissions.delete %} + + {% endif %} +
+
+ {% else %} + {% include table_template|default:'responsive_table.html' %} + {% endif %} + {% endwith %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+ {% if filter_form %} + {% include 'inc/search_panel.html' %} + {% endif %} + {% block sidebar %}{% endblock %} +
+
+{% endblock %} 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/cluster_list.html b/netbox/templates/virtualization/cluster_list.html deleted file mode 100644 index 6f5f058ad..000000000 --- a/netbox/templates/virtualization/cluster_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.virtualization.add_cluster %} - {% add_button 'virtualization:cluster_add' %} - {% import_button 'virtualization:cluster_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Clusters{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:cluster_bulk_edit' bulk_delete_url='virtualization:cluster_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html deleted file mode 100644 index f922b5efa..000000000 --- a/netbox/templates/virtualization/clustergroup_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.virtualization.add_clustergroup %} - {% add_button 'virtualization:clustergroup_add' %} - {% import_button 'virtualization:clustergroup_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Cluster Groups{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html deleted file mode 100644 index 48ff077c7..000000000 --- a/netbox/templates/virtualization/clustertype_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
- {% if perms.virtualization.add_clustertype %} - {% add_button 'virtualization:clustertype_add' %} - {% import_button 'virtualization:clustertype_import' %} - {% endif %} - {% export_button content_type %} -
-

{% block title %}Cluster Types{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/inc/virtualmachine_table.html b/netbox/templates/virtualization/inc/virtualmachine_table.html deleted file mode 100644 index ce249593e..000000000 --- a/netbox/templates/virtualization/inc/virtualmachine_table.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'utilities/obj_table.html' %} - -{% block extra_actions %} - {% if perms.virtualization.change_virtualmachine %} -
- - -
- {% endif %} -{% endblock %} \ No newline at end of file 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/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 821f956a2..74839b250 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,21 +1,14 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
- {% if perms.virtualization.add_virtualmachine %} - {% add_button 'virtualization:virtualmachine_add' %} - {% import_button 'virtualization:virtualmachine_import' %} +{% block bulk_buttons %} + {% if perms.virtualization.change_virtualmachine %} +
+ + +
{% endif %} - {% export_button content_type %} -
-

{% block title %}Virtual Machines{% endblock %}

-
-
- {% include 'virtualization/inc/virtualmachine_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %} -
-
- {% include 'inc/search_panel.html' %} -
-
{% endblock %} 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/tenancy/views.py b/netbox/tenancy/views.py index a53458694..0319a20b0 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -22,7 +22,6 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): permission_required = 'tenancy.view_tenantgroup' queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable - template_name = 'tenancy/tenantgroup_list.html' class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -60,7 +59,6 @@ class TenantListView(PermissionRequiredMixin, ObjectListView): filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable - template_name = 'tenancy/tenant_list.html' class TenantView(PermissionRequiredMixin, View): diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index ad6e8fd90..2cec2b532 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -27,3 +27,14 @@ COLOR_CHOICES = ( ('111111', 'Black'), ('ffffff', 'White'), ) + +# Keys for PostgreSQL advisory locks. These are arbitrary bigints used by +# the advisory_lock contextmanager. When a lock is acquired, +# one of these keys will be used to identify said lock. +# +# When adding a new key, pick something arbitrary and unique so +# that it is easily searchable in query logs. +ADVISORY_LOCK_KEYS = { + 'available-prefixes': 100100, + 'available-ips': 100200, +} 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 c9a857ad0..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