Merge pull request #4216 from netbox-community/develop

Release v2.7.7
This commit is contained in:
Jeremy Stretch 2020-02-20 14:56:27 -05:00 committed by GitHub
commit 5092641157
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
130 changed files with 998 additions and 1461 deletions

9
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -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

View File

@ -22,6 +22,10 @@ django-filter
# https://github.com/django-mptt/django-mptt # https://github.com/django-mptt/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 # Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus # https://github.com/korfuri/django-prometheus
django-prometheus django-prometheus

View File

@ -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. 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 dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report from extras.reports import Report
@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self): def test_console_connection(self):
# Check that every console port for every active device has a connection defined. # 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: if console_port.connected_endpoint is None:
self.log_failure( self.log_failure(
console_port.device, console_port.device,
@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
def test_power_connections(self): def test_power_connections(self):
# Check that every active device has at least two connected power supplies. # 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 connected_ports = 0
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None: if power_port.connected_endpoint is not None:

View File

@ -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 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 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 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. 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 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 for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via
`SENTINELS`/`SENTINEL_SERVICE`. `SENTINELS`/`SENTINEL_SERVICE`.

View File

@ -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) # v2.7.6 (2020-02-13)
## Bug Fixes ## Bug Fixes

View File

@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """ CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.circuit.change_circuittype %} {% if perms.circuit.change_circuittype %}

View File

@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm
table = tables.ProviderDetailTable table = tables.ProviderDetailTable
template_name = 'circuits/provider_list.html'
class ProviderView(PermissionRequiredMixin, View): class ProviderView(PermissionRequiredMixin, View):
@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuittype' permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
template_name = 'circuits/circuittype_list.html'
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable table = tables.CircuitTable
template_name = 'circuits/circuit_list.html'
class CircuitView(PermissionRequiredMixin, View): class CircuitView(PermissionRequiredMixin, View):

View File

@ -186,6 +186,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
unit_height = serializers.IntegerField( unit_height = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
) )
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
)
exclude = serializers.IntegerField( exclude = serializers.IntegerField(
required=False, required=False,
default=None default=None
@ -194,6 +197,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
required=False, required=False,
default=True default=True
) )
include_images = serializers.BooleanField(
required=False,
default=True
)
# #
@ -220,7 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', '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',
] ]

View File

@ -220,7 +220,13 @@ class RackViewSet(CustomFieldModelViewSet):
if data['render'] == 'svg': if data['render'] == 'svg':
# Render and return the elevation as an SVG drawing with the correct content type # 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') return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
else: else:

View File

@ -195,6 +195,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_DE9 = 'de-9' TYPE_DE9 = 'de-9'
TYPE_DB25 = 'db-25' TYPE_DB25 = 'db-25'
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12' TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45' TYPE_RJ45 = 'rj-45'
TYPE_USB_A = 'usb-a' TYPE_USB_A = 'usb-a'
@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
('Serial', ( ('Serial', (
(TYPE_DE9, 'DE-9'), (TYPE_DE9, 'DE-9'),
(TYPE_DB25, 'DB-25'), (TYPE_DB25, 'DB-25'),
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'), (TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'), (TYPE_RJ45, 'RJ-45'),
)), )),

192
netbox/dcim/elevations.py Normal file
View File

@ -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

View File

@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm): class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False,
widget=APISelect( widget=APISelect(
api_url="/api/dcim/sites/" api_url="/api/dcim/sites/"
) )
@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'tags', 'front_image', 'rear_image', 'comments', 'tags',
] ]
widgets = { widgets = {
'subdevice_role': StaticSelect2() 'subdevice_role': StaticSelect2()
@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField( device = forms.ModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
disabled=True,
widget=forms.HiddenInput() widget=forms.HiddenInput()
) )
type = forms.ChoiceField( type = forms.ChoiceField(
@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
class InterfaceFilterForm(DeviceComponentFilterForm): class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface model = Interface
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -2832,7 +2838,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
widget=APISelect( widget=APISelect(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', display_field='display_name',
full=True full=True,
additional_query_params={
'site_id': 'null',
},
) )
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
@ -2842,7 +2851,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', display_field='display_name',
full=True full=True,
additional_query_params={
'site_id': 'null',
},
) )
) )
tags = TagField( tags = TagField(
@ -2871,18 +2883,20 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or VC master)
if self.is_bound: if self.is_bound:
device = Device.objects.get(pk=self.data['device']) 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: else:
self.fields['lag'].queryset = Interface.objects.filter( device = self.instance.device
device__in=[self.instance.device, self.instance.device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG # 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): class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
@ -2942,7 +2956,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
widget=APISelect( widget=APISelect(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', display_field='display_name',
full=True full=True,
additional_query_params={
'site_id': 'null',
},
) )
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
@ -2951,7 +2968,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', 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 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): class InterfaceCSVForm(forms.ModelForm):
device = FlexibleModelChoiceField( device = FlexibleModelChoiceField(
@ -3043,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField( device = forms.ModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
disabled=True,
widget=forms.HiddenInput() widget=forms.HiddenInput()
) )
type = forms.ChoiceField( type = forms.ChoiceField(
@ -3090,7 +3115,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
widget=APISelect( widget=APISelect(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', display_field='display_name',
full=True full=True,
additional_query_params={
'site_id': 'null',
},
) )
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
@ -3099,7 +3127,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vlans/", api_url="/api/ipam/vlans/",
display_field='display_name', 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()], device__in=[device, device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG 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: else:
self.fields['lag'].choices = () self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True self.fields['lag'].widget.attrs['disabled'] = True
@ -4494,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerPanelForm(BootstrapMixin, forms.ModelForm): class PowerPanelForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False,
widget=APISelect( widget=APISelect(
api_url="/api/dcim/sites/", api_url="/api/dcim/sites/",
filter_for={ filter_for={

View File

@ -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)

View File

@ -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
),
]

View File

@ -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'),
),
]

View File

@ -1,7 +1,6 @@
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby from itertools import count, groupby
import svgwrite
import yaml import yaml
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User 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 import models
from django.db.models import Count, F, ProtectedError, Sum from django.db.models import Count, F, ProtectedError, Sum
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
@ -21,10 +19,11 @@ from timezone_field import TimeZoneField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.utils import foreground_color, to_meters from utilities.utils import to_meters
from .device_component_templates import ( from .device_component_templates import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
@ -350,180 +349,7 @@ class RackRole(ChangeLoggedModel):
) )
class RackElevationHelperMixin: class Rack(ChangeLoggedModel, CustomFieldModel):
"""
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):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. 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. Each Rack is assigned to a Site and (optionally) a RackGroup.
@ -835,6 +661,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
reserved_units[u] = r reserved_units[u] = r
return reserved_units 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): def get_0u_devices(self):
return self.devices.filter(position=0) 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 ' help_text='Parent devices house child devices in device bays. Leave blank '
'if this device type is neither a parent nor a child.' '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( comments = models.TextField(
blank=True blank=True
) )
@ -1056,6 +912,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# Save a copy of u_height for validation in clean() # Save a copy of u_height for validation in clean()
self._original_u_height = self.u_height 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): def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])
@ -1175,6 +1035,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'u_height': "Child device types must be 0U." '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 @property
def display_name(self): def display_name(self):
return '{} {}'.format(self.manufacturer.name, self.model) return '{} {}'.format(self.manufacturer.name, self.model)

View File

@ -41,7 +41,7 @@ DEVICE_LINK = """
""" """
REGION_ACTIONS = """ REGION_ACTIONS = """
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_region %} {% if perms.dcim.change_region %}
@ -50,7 +50,7 @@ REGION_ACTIONS = """
""" """
RACKGROUP_ACTIONS = """ RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """
""" """
RACKROLE_ACTIONS = """ RACKROLE_ACTIONS = """
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_rackrole %} {% if perms.dcim.change_rackrole %}
@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """
""" """
RACKRESERVATION_ACTIONS = """ RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_rackreservation %} {% if perms.dcim.change_rackreservation %}
@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """
""" """
MANUFACTURER_ACTIONS = """ MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_manufacturer %} {% if perms.dcim.change_manufacturer %}
@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """
""" """
DEVICEROLE_ACTIONS = """ DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_devicerole %} {% if perms.dcim.change_devicerole %}
@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """
""" """
PLATFORM_ACTIONS = """ PLATFORM_ACTIONS = """
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_platform %} {% if perms.dcim.change_platform %}
@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """
""" """
VIRTUALCHASSIS_ACTIONS = """ VIRTUALCHASSIS_ACTIONS = """
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_virtualchassis %} {% if perms.dcim.change_virtualchassis %}
@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable): class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn() name = tables.LinkColumn()
enabled = BooleanColumn()
class Meta(InterfaceTable.Meta): class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name') order_by = ('parent', 'name')
fields = ('pk', 'parent', 'name', 'type', 'description', 'cable') fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable') sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable): class FrontPortTable(BaseTable):

View File

@ -31,6 +31,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import ( from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@ -152,7 +153,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RegionFilterSet filterset = filters.RegionFilterSet
filterset_form = forms.RegionFilterForm filterset_form = forms.RegionFilterForm
table = tables.RegionTable table = tables.RegionTable
template_name = 'dcim/region_list.html'
class RegionCreateView(PermissionRequiredMixin, ObjectEditView): class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
@ -191,7 +191,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.SiteFilterSet filterset = filters.SiteFilterSet
filterset_form = forms.SiteFilterForm filterset_form = forms.SiteFilterForm
table = tables.SiteTable table = tables.SiteTable
template_name = 'dcim/site_list.html'
class SiteView(PermissionRequiredMixin, View): class SiteView(PermissionRequiredMixin, View):
@ -271,7 +270,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackGroupFilterSet filterset = filters.RackGroupFilterSet
filterset_form = forms.RackGroupFilterForm filterset_form = forms.RackGroupFilterForm
table = tables.RackGroupTable table = tables.RackGroupTable
template_name = 'dcim/rackgroup_list.html'
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@ -308,7 +306,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackrole' permission_required = 'dcim.view_rackrole'
queryset = RackRole.objects.annotate(rack_count=Count('racks')) queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable table = tables.RackRoleTable
template_name = 'dcim/rackrole_list.html'
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@ -350,7 +347,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackFilterSet filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm filterset_form = forms.RackFilterForm
table = tables.RackDetailTable table = tables.RackDetailTable
template_name = 'dcim/rack_list.html'
class RackElevationListView(PermissionRequiredMixin, View): class RackElevationListView(PermissionRequiredMixin, View):
@ -474,7 +470,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackReservationFilterSet filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
template_name = 'dcim/rackreservation_list.html' action_buttons = ()
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
@ -533,7 +529,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
platform_count=Count('platforms', distinct=True), platform_count=Count('platforms', distinct=True),
) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
template_name = 'dcim/manufacturer_list.html'
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
@ -571,7 +566,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
template_name = 'dcim/devicetype_list.html'
class DeviceTypeView(PermissionRequiredMixin, View): class DeviceTypeView(PermissionRequiredMixin, View):
@ -995,7 +989,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_devicerole' permission_required = 'dcim.view_devicerole'
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
template_name = 'dcim/devicerole_list.html'
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@ -1031,7 +1024,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_platform' permission_required = 'dcim.view_platform'
queryset = Platform.objects.all() queryset = Platform.objects.all()
table = tables.PlatformTable table = tables.PlatformTable
template_name = 'dcim/platform_list.html'
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
@ -1190,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
device = get_object_or_404(Device, pk=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' '_connected_interface__device'
) )
@ -1292,7 +1284,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsolePortFilterSet filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortDetailTable table = tables.ConsolePortDetailTable
template_name = 'dcim/consoleport_list.html' action_buttons = ('import', 'export')
class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1345,7 +1337,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsoleServerPortFilterSet filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortDetailTable table = tables.ConsoleServerPortDetailTable
template_name = 'dcim/consoleserverport_list.html' action_buttons = ('import', 'export')
class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1410,7 +1402,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPortFilterSet filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortDetailTable table = tables.PowerPortDetailTable
template_name = 'dcim/powerport_list.html' action_buttons = ('import', 'export')
class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1463,7 +1455,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerOutletFilterSet filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletDetailTable table = tables.PowerOutletDetailTable
template_name = 'dcim/poweroutlet_list.html' action_buttons = ('import', 'export')
class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1528,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InterfaceFilterSet filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceDetailTable table = tables.InterfaceDetailTable
template_name = 'dcim/interface_list.html' action_buttons = ('import', 'export')
class InterfaceView(PermissionRequiredMixin, View): class InterfaceView(PermissionRequiredMixin, View):
@ -1630,7 +1622,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.FrontPortFilterSet filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortDetailTable table = tables.FrontPortDetailTable
template_name = 'dcim/frontport_list.html' action_buttons = ('import', 'export')
class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1695,7 +1687,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RearPortFilterSet filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortDetailTable table = tables.RearPortDetailTable
template_name = 'dcim/rearport_list.html' action_buttons = ('import', 'export')
class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1762,7 +1754,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceBayFilterSet filterset = filters.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayDetailTable table = tables.DeviceBayDetailTable
template_name = 'dcim/devicebay_list.html' action_buttons = ('import', 'export')
class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
@ -1961,7 +1953,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.CableFilterSet filterset = filters.CableFilterSet
filterset_form = forms.CableFilterForm filterset_form = forms.CableFilterForm
table = tables.CableTable table = tables.CableTable
template_name = 'dcim/cable_list.html' action_buttons = ('import', 'export')
class CableView(PermissionRequiredMixin, View): class CableView(PermissionRequiredMixin, View):
@ -2233,7 +2225,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InventoryItemFilterSet filterset = filters.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_list.html' action_buttons = ('import', 'export')
class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
@ -2289,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm filterset_form = forms.VirtualChassisFilterForm
template_name = 'dcim/virtualchassis_list.html' action_buttons = ('export',)
class VirtualChassisCreateView(PermissionRequiredMixin, View): class VirtualChassisCreateView(PermissionRequiredMixin, View):
@ -2533,7 +2525,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPanelFilterSet filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm filterset_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable table = tables.PowerPanelTable
template_name = 'dcim/powerpanel_list.html'
class PowerPanelView(PermissionRequiredMixin, View): class PowerPanelView(PermissionRequiredMixin, View):
@ -2602,7 +2593,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerFeedFilterSet filterset = filters.PowerFeedFilterSet
filterset_form = forms.PowerFeedFilterForm filterset_form = forms.PowerFeedFilterForm
table = tables.PowerFeedTable table = tables.PowerFeedTable
template_name = 'dcim/powerfeed_list.html'
class PowerFeedView(PermissionRequiredMixin, View): class PowerFeedView(PermissionRequiredMixin, View):

View File

@ -1,28 +1,8 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import redis
class ExtrasConfig(AppConfig): class ExtrasConfig(AppConfig):
name = "extras" name = "extras"
def ready(self): def ready(self):
import extras.signals 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."
)

View File

@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem from .models import ConfigContext, ObjectChange, Tag, TaggedItem
TAG_ACTIONS = """ TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.taggit.change_tag %} {% if perms.taggit.change_tag %}

View File

@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters, forms from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
@ -34,7 +35,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.TagFilterSet filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm filterset_form = forms.TagFilterForm
table = TagTable table = TagTable
template_name = 'extras/tag_list.html' action_buttons = ()
class TagView(PermissionRequiredMixin, View): class TagView(PermissionRequiredMixin, View):
@ -111,7 +112,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = ConfigContextTable table = ConfigContextTable
template_name = 'extras/configcontext_list.html' action_buttons = ('add',)
class ConfigContextView(PermissionRequiredMixin, View): class ConfigContextView(PermissionRequiredMixin, View):
@ -191,6 +192,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable table = ObjectChangeTable
template_name = 'extras/objectchange_list.html' template_name = 'extras/objectchange_list.html'
action_buttons = ('export',)
class ObjectChangeView(PermissionRequiredMixin, View): class ObjectChangeView(PermissionRequiredMixin, View):
@ -206,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
orderable=False 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', { return render(request, 'extras/objectchange.html', {
'objectchange': objectchange, '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_table': related_changes_table,
'related_changes_count': related_changes.count() 'related_changes_count': related_changes.count()
}) })

View File

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet
from ipam import filters from ipam import filters
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from utilities.api import FieldChoicesViewSet, ModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import get_subquery from utilities.utils import get_subquery
from . import serializers from . import serializers
@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet):
filterset_class = filters.PrefixFilterSet filterset_class = filters.PrefixFilterSet
@action(detail=True, url_path='available-prefixes', methods=['get', 'post']) @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None): def available_prefixes(self, request, pk=None):
""" """
A convenience method for returning available child prefixes within a parent. 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) prefix = get_object_or_404(Prefix, pk=pk)
available_prefixes = prefix.get_available_prefixes() available_prefixes = prefix.get_available_prefixes()
@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, url_path='available-ips', methods=['get', 'post']) @action(detail=True, url_path='available-ips', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None): def available_ips(self, request, pk=None):
""" """
A convenience method for returning available IP addresses within a prefix. By default, the number of IPs 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, 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. 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) prefix = get_object_or_404(Prefix, pk=pk)

View File

@ -154,10 +154,24 @@ class NetHostContained(Lookup):
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
#
# Transforms
#
class NetMaskLength(Transform): class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN' function = 'MASKLEN'
lookup_name = 'net_mask_length'
@property @property
def output_field(self): def output_field(self):
return IntegerField() return IntegerField()
class Host(Transform):
function = 'HOST'
lookup_name = 'host'
class Inet(Transform):
function = 'INET'
lookup_name = 'inet'

View File

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.db.models.expressions import RawSQL
from ipam.lookups import Host, Inet
class IPAddressManager(models.Manager): class IPAddressManager(models.Manager):
@ -13,4 +14,4 @@ class IPAddressManager(models.Manager):
IP address as a /32 or /128. IP address as a /32 or /128.
""" """
qs = super().get_queryset() 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')))

View File

@ -26,7 +26,7 @@ RIR_UTILIZATION = """
""" """
RIR_ACTIONS = """ RIR_ACTIONS = """
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_rir %} {% if perms.ipam.change_rir %}
@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """
""" """
ROLE_ACTIONS = """ ROLE_ACTIONS = """
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_role %} {% if perms.ipam.change_role %}
@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """
""" """
VLANGROUP_ACTIONS = """ VLANGROUP_ACTIONS = """
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% with next_vid=record.get_next_available_vid %} {% with next_vid=record.get_next_available_vid %}

View File

@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
filterset_form = forms.VRFFilterForm filterset_form = forms.VRFFilterForm
table = tables.VRFTable table = tables.VRFTable
template_name = 'ipam/vrf_list.html'
class VRFView(PermissionRequiredMixin, View): class VRFView(PermissionRequiredMixin, View):
@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView):
queryset = Aggregate.objects.prefetch_related('rir').annotate( queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
) )
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
filterset_form = forms.AggregateFilterForm filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable table = tables.AggregateDetailTable
@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_role' permission_required = 'ipam.view_role'
queryset = Role.objects.all() queryset = Role.objects.all()
table = tables.RoleTable table = tables.RoleTable
template_name = 'ipam/role_list.html'
class RoleCreateView(PermissionRequiredMixin, ObjectEditView): class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable table = tables.IPAddressDetailTable
template_name = 'ipam/ipaddress_list.html'
class IPAddressView(PermissionRequiredMixin, View): class IPAddressView(PermissionRequiredMixin, View):
@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
template_name = 'ipam/vlangroup_list.html'
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
filterset_form = forms.VLANFilterForm filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable table = tables.VLANDetailTable
template_name = 'ipam/vlan_list.html'
class VLANView(PermissionRequiredMixin, View): class VLANView(PermissionRequiredMixin, View):
@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable table = tables.ServiceTable
template_name = 'ipam/service_list.html' action_buttons = ('export',)
class ServiceView(PermissionRequiredMixin, View): class ServiceView(PermissionRequiredMixin, View):

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.7.6' VERSION = '2.7.7'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -179,26 +179,10 @@ nav ul.pagination {
/* Racks */ /* Racks */
div.rack_header { div.rack_header {
margin-left: 36px; margin-left: 30px;
text-align: center; text-align: center;
width: 230px; 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 */ /* Devices */
table.component-list td.subtable { table.component-list td.subtable {

View File

@ -14,7 +14,7 @@ text {
background-color: #f0f0f0; background-color: #f0f0f0;
fill: none; fill: none;
stroke: black; stroke: black;
stroke-width: 3px; stroke-width: 2px;
} }
.slot { .slot {
fill: #f7f7f7; fill: #f7f7f7;
@ -56,7 +56,6 @@ text {
.blocked:hover+.add-device { .blocked:hover+.add-device {
fill: none; fill: none;
} }
.unit { .unit {
margin: 0; margin: 0;
padding: 5px 0px; padding: 5px 0px;
@ -65,3 +64,6 @@ text {
font-size: 10px; font-size: 10px;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
} }
.hidden {
visibility: hidden;
}

View File

@ -42,17 +42,23 @@ $(document).ready(function() {
return s.substring(0, num_chars); // Trim to first num_chars chars return s.substring(0, num_chars); // Trim to first num_chars chars
} }
var slug_field = $('#id_slug'); var slug_field = $('#id_slug');
slug_field.change(function() {
$(this).attr('_changed', true);
});
if (slug_field) { if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source')); var slug_source = $('#id_' + slug_field.attr('slug-source'));
var slug_length = slug_field.attr('maxlength'); 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() { slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) { if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50))); 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 // Bulk edit nullification
@ -190,15 +196,18 @@ $(document).ready(function() {
$.each(element.attributes, function(index, attr){ $.each(element.attributes, function(index, attr){
if (attr.name.includes("data-additional-query-param-")){ if (attr.name.includes("data-additional-query-param-")){
var param_name = attr.name.split("data-additional-query-param-")[1]; var param_name = attr.name.split("data-additional-query-param-")[1];
if (param_name in parameters) {
if (Array.isArray(parameters[param_name])) { $.each($.parseJSON(attr.value), function(index, value) {
parameters[param_name].push(attr.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 { } else {
parameters[param_name] = [parameters[param_name], attr.value] parameters[param_name] = value;
} }
} else { });
parameters[param_name] = attr.value;
}
} }
}); });

View File

@ -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;
});

View File

@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
role = DynamicModelMultipleChoiceField( role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(), queryset=SecretRole.objects.all(),
to_field_name='slug', to_field_name='slug',
required=True, required=False,
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/secrets/secret-roles/", api_url="/api/secrets/secret-roles/",
value_field="slug", value_field="slug",

View File

@ -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 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. 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 A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
of 64 bytes during encryption in order to protect short strings from ciphertext analysis. a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
""" """
device = models.ForeignKey( device = models.ForeignKey(
to='dcim.Device', to='dcim.Device',
@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
blank=True blank=True
) )
ciphertext = models.BinaryField( 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 editable=False
) )
hash = models.CharField( hash = models.CharField(
@ -388,11 +388,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
else: else:
pad_length = 0 pad_length = 0
# Python 2 compatibility header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
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])
return header + s + os.urandom(pad_length) return header + s + os.urandom(pad_length)

View File

@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import SecretRole, Secret from .models import SecretRole, Secret
SECRETROLE_ACTIONS = """ SECRETROLE_ACTIONS = """
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.secrets.change_secretrole %} {% if perms.secrets.change_secretrole %}

View File

@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase):
class SecretTestCase(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): def test_01_encrypt_decrypt(self):
""" """
Test basic encryption and decryption functionality using a random master key. Test basic encryption and decryption functionality using a random master key.
""" """
plaintext = string.printable * 2 plaintext = string.printable * 2
secret_key = generate_random_key()
s = Secret(plaintext=plaintext) s = Secret(plaintext=plaintext)
s.encrypt(secret_key) s.encrypt(self.secret_key)
# Ensure plaintext is deleted upon encryption # Ensure plaintext is deleted upon encryption
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.") 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") self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
# Test decryption # Test decryption
s.decrypt(secret_key) s.decrypt(self.secret_key)
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext") self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
def test_02_ciphertext_uniqueness(self): 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. Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
""" """
plaintext = "1234567890abcdef" plaintext = "1234567890abcdef"
secret_key = generate_random_key()
ivs = [] ivs = []
ciphertexts = [] ciphertexts = []
for i in range(1, 51): for i in range(1, 51):
s = Secret(plaintext=plaintext) s = Secret(plaintext=plaintext)
s.encrypt(secret_key) s.encrypt(self.secret_key)
ivs.append(s.ciphertext[0:16]) ivs.append(s.ciphertext[0:16])
ciphertexts.append(s.ciphertext[16:32]) ciphertexts.append(s.ciphertext[16:32])
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1] duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!") self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1] 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!") 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)

View File

@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'secrets.view_secretrole' permission_required = 'secrets.view_secretrole'
queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable table = tables.SecretRoleTable
template_name = 'secrets/secretrole_list.html'
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.SecretFilterSet filterset = filters.SecretFilterSet
filterset_form = forms.SecretFilterForm filterset_form = forms.SecretFilterForm
table = tables.SecretTable table = tables.SecretTable
template_name = 'secrets/secret_list.html' action_buttons = ('import', 'export')
class SecretView(PermissionRequiredMixin, View): class SecretView(PermissionRequiredMixin, View):

View File

@ -49,7 +49,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a> <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.circuits.add_circuit %}
{% add_button 'circuits:circuit_add' %}
{% import_button 'circuits:circuit_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Circuits{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.circuits.add_circuittype %}
{% add_button 'circuits:circuittype_add' %}
{% import_button 'circuits:circuittype_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Circuit Types{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -54,7 +54,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a> <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.circuits.add_provider %}
{% add_button 'circuits:provider_add' %}
{% import_button 'circuits:provider_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Providers{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -31,7 +31,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a> <a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,20 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_cable %}
{% import_button 'dcim:cable_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Cables{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Console Ports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Console Server Ports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -119,7 +119,7 @@
{% endif %} {% endif %}
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a> <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +1,24 @@
{% extends '_base.html' %} {% extends 'utilities/obj_list.html' %}
{% load buttons %}
{% block content %} {% block bulk_buttons %}
<div class="pull-right noprint"> {% if perms.dcim.change_device %}
{% if perms.dcim.add_device %} <div class="btn-group">
{% add_button 'dcim:device_add' %} <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{% import_button 'dcim:device_import' %} <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
</ul>
</div>
{% endif %}
{% if perms.dcim.add_virtualchassis %}
<button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
</button>
{% endif %} {% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Devices{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Device Bays{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_devicerole %}
{% add_button 'dcim:devicerole_add' %}
{% import_button 'dcim:devicerole_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Device Roles{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicerole_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -54,7 +54,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a> <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -109,6 +109,30 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Front Image</td>
<td>
{% if devicetype.front_image %}
<a href="{{ devicetype.front_image.url }}">
<img src="{{ devicetype.front_image.url }}" alt="{{ devicetype.front_image.name }}" class="img-responsive" />
</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Rear Image</td>
<td>
{% if devicetype.rear_image %}
<a href="{{ devicetype.rear_image.url }}">
<img src="{{ devicetype.rear_image.url }}" alt="{{ devicetype.rear_image.name }}" class="img-responsive" />
</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Instances</td> <td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td> <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@ -14,6 +14,13 @@
{% render_field form.subdevice_role %} {% render_field form.subdevice_role %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Rack Images</strong></div>
<div class="panel-body">
{% render_field form.front_image %}
{% render_field form.rear_image %}
</div>
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div> <div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_devicetype %}
{% add_button 'dcim:devicetype_add' %}
{% import_button 'dcim:devicetype_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Device Types{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Front Ports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,24 +0,0 @@
{% extends 'utilities/obj_table.html' %}
{% block extra_actions %}
{% if perms.dcim.change_device %}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
</ul>
</div>
{% endif %}
{% if perms.dcim.add_virtualchassis %}
<button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
</button>
{% endif %}
{% endblock %}

View File

@ -1,7 +1,4 @@
{% load helpers %} {% load helpers %}
<div class="rack_frame"> <div class="rack_frame">
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}"></object>
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>
</div> </div>

View File

@ -34,7 +34,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a> <a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Interfaces{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_devicetype %}
{% import_button 'dcim:inventoryitem_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Inventory Items{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_manufacturer %}
{% add_button 'dcim:manufacturer_add' %}
{% import_button 'dcim:manufacturer_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Manufacturers{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:manufacturer_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_platform %}
{% add_button 'dcim:platform_add' %}
{% import_button 'dcim:platform_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Platforms{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:platform_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -52,7 +52,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Changelog</a> <a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -121,18 +121,8 @@
</tr> </tr>
</table> </table>
</div> </div>
<div class="panel panel-default"> {% include 'inc/custom_fields_panel.html' with obj=powerfeed %}
<div class="panel-heading"> {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if powerfeed.comments %}
{{ powerfeed.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">
@ -162,6 +152,18 @@
</tr> </tr>
</table> </table>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if powerfeed.comments %}
{{ powerfeed.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_powerfeed %}
{% add_button 'dcim:powerfeed_add' %}
{% import_button 'dcim:powerfeed_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Power Feeds{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Power Outlets{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -48,7 +48,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Changelog</a> <a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_powerpanel %}
{% add_button 'dcim:powerpanel_add' %}
{% import_button 'dcim:powerpanel_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Power Panels{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Power Ports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -2,6 +2,7 @@
{% load buttons %} {% load buttons %}
{% load custom_links %} {% load custom_links %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block header %} {% block header %}
<div class="row noprint"> <div class="row noprint">
@ -45,6 +46,9 @@
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1> <h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=rack %} {% include 'inc/created_updated.html' with obj=rack %}
<div class="pull-right noprint"> <div class="pull-right noprint">
<button class="btn btn-sm btn-default toggle-images" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button>
{% custom_links rack %} {% custom_links rack %}
</div> </div>
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
@ -53,7 +57,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a> <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -368,9 +372,5 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
<script type="text/javascript"> <script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %} {% endblock %}

View File

@ -1,8 +1,12 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block content %} {% block content %}
<div class="btn-group pull-right noprint" role="group"> <div class="btn-group pull-right noprint" role="group">
<button class="btn btn-default toggle-images" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div> </div>
@ -41,9 +45,5 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
<script type="text/javascript"> <script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %} {% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_rack %}
{% add_button 'dcim:rack_add' %}
{% import_button 'dcim:rack_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Racks{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_rackgroup %}
{% add_button 'dcim:rackgroup_add' %}
{% import_button 'dcim:rackgroup_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Rack Groups{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,14 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_rackrole %}
{% add_button 'dcim:rackrole_add' %}
{% import_button 'dcim:rackrole_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Rack Roles{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Rear Ports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_region %}
{% add_button 'dcim:region_add' %}
{% import_button 'dcim:region_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Regions{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -60,7 +60,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a> <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.dcim.add_site %}
{% add_button 'dcim:site_add' %}
{% import_button 'dcim:site_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Sites{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,19 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.extras.add_configcontext %}
{% add_button 'extras:configcontext_add' %}
{% endif %}
</div>
<h1>{% block title %}Config Contexts{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,12 +1,12 @@
{% extends base_template %} {% 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 %} {% block content %}
{% if obj %}<h1>{{ obj }}</h1>{% endif %} {% if obj %}<h1>{{ obj }}</h1>{% endif %}
{% include 'panel_table.html' %} {% include 'panel_table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="text-muted"> <div class="text-muted">
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 %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<div class="row noprint"> <div class="row noprint">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li> <li><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
{% if objectchange.related_object.get_absolute_url %} {% if objectchange.related_object.get_absolute_url %}
<li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li> <li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
{% elif objectchange.changed_object.get_absolute_url %} {% elif objectchange.changed_object.get_absolute_url %}
@ -83,6 +83,35 @@
</tr> </tr>
</table> </table>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Difference</strong>
<div class="btn-group btn-group-xs pull-right noprint">
<a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous
</a>
<a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
Next <span class="fa fa-chevron-right" aria-hidden="true"></span>
</a>
</div>
</div>
<div class="panel-body">
{% if diff_added == diff_removed %}
<span class="text-muted" style="margin-left: 10px;">
{% if objectchange.action == 'create' %}
Object created
{% elif objectchange.action == 'delete' %}
Object deleted
{% else %}
No changes
{% endif %}
</span>
{% else %}
<pre style="background-color: #ffdce0;">{{ diff_removed|render_json }}</pre>
<pre style="background-color: #cdffd8;">{{ diff_added|render_json }}</pre>
{% endif %}
</div>
</div>
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -1,20 +1,9 @@
{% extends '_base.html' %} {% extends 'utilities/obj_list.html' %}
{% load buttons %}
{% block content %} {% block title %}Change Log{% endblock %}
<div class="pull-right noprint">
{% export_button content_type %} {% block sidebar %}
</div> <div class="text-muted">
<h1>{% block title %}Changelog{% endblock %}</h1> Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' %}
<div class="text-muted text-right">
Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
</div>
</div> </div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -44,7 +44,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a> <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,14 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<h1>{% block title %}Tags{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -284,7 +284,7 @@
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Changelog</strong> <strong>Change Log</strong>
</div> </div>
{% if changelog and perms.extras.view_objectchange %} {% if changelog and perms.extras.view_objectchange %}
<div class="list-group"> <div class="list-group">

View File

@ -473,7 +473,7 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li class="dropdown-header">Logging</li> <li class="dropdown-header">Logging</li>
<li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}> <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
<a href="{% url 'extras:objectchange_list' %}">Changelog</a> <a href="{% url 'extras:objectchange_list' %}">Change Log</a>
</li> </li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li> <li class="dropdown-header">Miscellaneous</li>

View File

@ -48,7 +48,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a> <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,31 +1,14 @@
{% extends '_base.html' %} {% extends 'utilities/obj_list.html' %}
{% load buttons %}
{% load humanize %} {% load humanize %}
{% block content %} {% block sidebar %}
<div class="pull-right noprint"> <div class="panel panel-default">
{% if perms.ipam.add_aggregate %} <div class="panel-heading">
{% add_button 'ipam:aggregate_add' %} <strong><i class="fa fa-bar-chart"></i> Statistics</strong>
{% import_button 'ipam:aggregate_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Aggregates{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-bar-chart"></i> Statistics</strong>
</div>
<ul class="list-group">
<li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
<li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
</ul>
</div> </div>
</div> <ul class="list-group">
</div> <li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
<li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
</ul>
</div>
{% endblock %} {% endblock %}

View File

@ -14,7 +14,7 @@
</td> </td>
<td>{{ service.description }}</td> <td>{{ service.description }}</td>
<td class="text-right noprint"> <td class="text-right noprint">
<a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Changelog"> <a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_service %} {% if perms.ipam.change_service %}

View File

@ -49,7 +49,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a> <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipam.add_ipaddress %}
{% add_button 'ipam:ipaddress_add' %}
{% import_button 'ipam:ipaddress_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}IP Addresses{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -69,7 +69,7 @@
{% endif %} {% endif %}
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a> <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,26 +1,9 @@
{% extends '_base.html' %} {% extends 'utilities/obj_list.html' %}
{% load buttons %}
{% load helpers %} {% load helpers %}
{% block content %} {% block buttons %}
<div class="pull-right noprint">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{% url 'ipam:prefix_list' %}{% querystring request expand=None page=1 %}" class="btn btn-default{% if not request.GET.expand %} active{% endif %}">Collapse</a> <a href="{% url 'ipam:prefix_list' %}{% querystring request expand=None page=1 %}" class="btn btn-default{% if not request.GET.expand %} active{% endif %}">Collapse</a>
<a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a> <a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
</div> </div>
{% if perms.ipam.add_prefix %}
{% add_button 'ipam:prefix_add' %}
{% import_button 'ipam:prefix_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Prefixes{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,6 @@
{% extends '_base.html' %} {% extends 'utilities/obj_list.html' %}
{% load buttons %}
{% load humanize %}
{% block content %} {% block buttons %}
<div class="pull-right noprint">
{% if request.GET.family == '6' %} {% if request.GET.family == '6' %}
<a href="{% url 'ipam:rir_list' %}" class="btn btn-default"> <a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
<span class="fa fa-table" aria-hidden="true"></span> <span class="fa fa-table" aria-hidden="true"></span>
@ -15,22 +12,12 @@
IPv6 Stats IPv6 Stats
</a> </a>
{% endif %} {% endif %}
{% if perms.ipam.add_rir %} {% endblock %}
{% add_button 'ipam:rir_add' %}
{% import_button 'ipam:rir_import' %} {% block sidebar %}
{% endif %} {% if request.GET.family == '6' %}
{% export_button content_type %} <div class="alert alert-info">
</div> <i class="fa fa-info-circle"></i> Numbers shown indicate /64 prefixes.
<h1>{% block title %}RIRs{% endblock %}</h1> </div>
<div class="row"> {% endif %}
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %}
{% if request.GET.family == '6' %}
<div class="alert alert-info pull-right"><strong>Note:</strong> Numbers shown indicate /64 prefixes.</div>
{% endif %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipam.add_role %}
{% add_button 'ipam:role_add' %}
{% import_button 'ipam:role_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:role_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Services{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -55,7 +55,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a> <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipam.add_vlan %}
{% add_button 'ipam:vlan_add' %}
{% import_button 'ipam:vlan_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}VLANs{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipam.add_vlangroup %}
{% add_button 'ipam:vlangroup_add' %}
{% import_button 'ipam:vlangroup_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}VLAN Groups{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -46,7 +46,7 @@
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a> <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Change Log</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

Some files were not shown because too many files have changed in this diff Show More