Merge branch 'develop' into develop-2.9

This commit is contained in:
Jeremy Stretch 2020-06-15 16:04:32 -04:00
commit e917535380
27 changed files with 384 additions and 157 deletions

View File

@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
``` ```
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty. The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
## Bulk Object Creation
The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices:
```
curl -X POST -H "Authorization: Token <TOKEN>" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[
{"name": "device1", "device_type": 24, "device_role": 17, "site": 6},
{"name": "device2", "device_type": 24, "device_role": 17, "site": 6},
{"name": "device3", "device_type": 24, "device_role": 17, "site": 6},
]'
```
Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created.

View File

@ -13,6 +13,14 @@ ADMINS = [
--- ---
## ALLOWED_URL_SCHEMES
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable).
---
## BANNER_TOP ## BANNER_TOP
## BANNER_BOTTOM ## BANNER_BOTTOM

View File

@ -1,16 +1,27 @@
# NetBox v2.8 # NetBox v2.8
## v2.8.6 (FUTURE) ## v2.8.6 (2020-06-15)
### Enhancements ### Enhancements
* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI * [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks
* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix
* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses
### Bug Fixes ### Bug Fixes
* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret * [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer * [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts
* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints * [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints
* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports
* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table
* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes
* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups
* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates
--- ---

View File

@ -502,13 +502,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
class FrontPortViewSet(ModelViewSet): class FrontPortViewSet(CableTraceMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet filterset_class = filters.FrontPortFilterSet
class RearPortViewSet(ModelViewSet): class RearPortViewSet(CableTraceMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet filterset_class = filters.RearPortFilterSet

View File

@ -21,11 +21,11 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BOOLEAN_WITH_BLANK_CHOICES, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
form_from_model, JSONField, LabeledComponentForm, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, LabeledComponentForm, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2,
StaticSelect2Multiple, TagFilterField, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup, VirtualMachine from virtualization.models import Cluster, ClusterGroup, VirtualMachine
from .choices import * from .choices import *
@ -363,7 +363,12 @@ 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(),
widget=APISelect(
filter_for={
'parent': 'site_id',
}
)
) )
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.all(),
@ -729,21 +734,32 @@ class RackElevationFilterForm(RackFilterForm):
# #
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
rack = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=forms.HiddenInput() widget=APISelect(
) filter_for={
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain 'rack_group': 'site_id',
# the multi-line <select> widget for easy selection of multiple rack units. 'rack': 'site_id',
units = SimpleArrayField(
base_field=forms.IntegerField(),
widget=ArrayFieldSelectMultiple(
attrs={
'size': 10,
} }
) )
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
'rack': 'group_id'
}
)
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all()
)
units = NumericArrayField(
base_field=forms.IntegerField(),
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
)
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=User.objects.order_by(
'username' 'username'
@ -760,23 +776,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rack unit choices
if hasattr(self.instance, 'rack'):
self.fields['units'].widget.choices = self._get_unit_choices()
def _get_unit_choices(self):
rack = self.instance.rack
reserved_units = []
for resv in rack.reservations.exclude(pk=self.instance.pk):
for u in resv.units:
reserved_units.append(u)
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
return unit_choices
class RackReservationCSVForm(CSVModelForm): class RackReservationCSVForm(CSVModelForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
@ -1220,11 +1219,21 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=PowerOutletTemplate.objects.all(), queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
feed_leg = forms.ChoiceField( feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices), choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False, required=False,
@ -1232,7 +1241,18 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
) )
class Meta: class Meta:
nullable_fields = ('type', 'feed_leg') nullable_fields = ('type', 'power_port', 'feed_leg')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
if 'device_type' in self.initial:
device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
else:
self.fields['power_port'].choices = ()
self.fields['power_port'].widget.attrs['disabled'] = True
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
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,
@ -72,15 +72,6 @@ RACKROLE_ACTIONS = """
{% endif %} {% endif %}
""" """
RACK_ROLE = """
{% if record.role %}
{% load helpers %}
<label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
{% else %}
&mdash;
{% endif %}
"""
RACK_DEVICE_COUNT = """ RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a> <a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
""" """
@ -137,11 +128,6 @@ PLATFORM_ACTIONS = """
{% endif %} {% endif %}
""" """
DEVICE_ROLE = """
{% load helpers %}
<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_LABEL = """ STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span> <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
""" """
@ -325,9 +311,7 @@ class RackTable(BaseTable):
status = tables.TemplateColumn( status = tables.TemplateColumn(
template_code=STATUS_LABEL template_code=STATUS_LABEL
) )
role = tables.TemplateColumn( role = ColoredLabelColumn()
template_code=RACK_ROLE
)
u_height = tables.TemplateColumn( u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U", template_code="{{ record.u_height }}U",
verbose_name='Height' verbose_name='Height'
@ -810,8 +794,7 @@ class DeviceTable(BaseTable):
viewname='dcim:rack', viewname='dcim:rack',
args=[Accessor('rack.pk')] args=[Accessor('rack.pk')]
) )
device_role = tables.TemplateColumn( device_role = ColoredLabelColumn(
template_code=DEVICE_ROLE,
verbose_name='Role' verbose_name='Role'
) )
device_type = tables.LinkColumn( device_type = tables.LinkColumn(

View File

@ -8,9 +8,9 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN from ipam.models import VLAN
from extras.models import Graph from extras.models import Graph
@ -566,6 +566,111 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
] ]
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = FrontPortTemplate
brief_fields = ['id', 'name', 'url']
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
)
RearPortTemplate.objects.bulk_create(rear_port_templates)
front_port_templates = (
FrontPortTemplate(
device_type=devicetype,
name='Front Port Template 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[0]
),
FrontPortTemplate(
device_type=devicetype,
name='Front Port Template 2',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[1]
),
FrontPortTemplate(
device_type=devicetype,
name='Front Port Template 3',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[2]
),
)
FrontPortTemplate.objects.bulk_create(front_port_templates)
cls.create_data = [
{
'device_type': devicetype.pk,
'name': 'Front Port Template 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[3].pk,
'position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 5',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[4].pk,
'position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 6',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[5].pk,
'position': 1,
},
]
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = RearPortTemplate
brief_fields = ['id', 'name', 'url']
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
)
RearPortTemplate.objects.bulk_create(rear_port_templates)
cls.create_data = [
{
'device_type': devicetype.pk,
'name': 'Rear Port Template 4',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device_type': devicetype.pk,
'name': 'Rear Port Template 5',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device_type': devicetype.pk,
'name': 'Rear Port Template 6',
'type': PortTypeChoices.TYPE_8P8C,
},
]
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate model = DeviceBayTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
@ -1146,6 +1251,35 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
def test_trace_interface(self):
"""
Test tracing an Interface cable.
"""
interface_a = Interface.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface_b = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], interface_a.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface_b.name)
class FrontPortTest(APIViewTestCases.APIViewTestCase): class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort
@ -1200,6 +1334,35 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
def test_trace_frontport(self):
"""
Test tracing a FrontPort cable.
"""
frontport = FrontPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_frontport')
url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], frontport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class RearPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort model = RearPort
@ -1238,6 +1401,35 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
def test_trace_rearport(self):
"""
Test tracing a RearPort cable.
"""
rearport = RearPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_rearport')
url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], rearport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class DeviceBayTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay model = DeviceBay

View File

@ -198,7 +198,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
'units': [10, 11, 12], 'units': "10,11,12",
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'Rack reservation', 'description': 'Rack reservation',

View File

@ -430,18 +430,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
help_text="Commit changes to the database (uncheck for a dry-run)" help_text="Commit changes to the database (uncheck for a dry-run)"
) )
def __init__(self, vars, *args, commit_default=True, **kwargs): def __init__(self, *args, **kwargs):
# Dynamically populate fields for variables
for name, var in vars.items():
self.base_fields[name] = var.as_field()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Toggle default commit behavior based on Meta option
if not commit_default:
self.fields['_commit'].initial = False
# Move _commit to the end of the form # Move _commit to the end of the form
commit = self.fields.pop('_commit') commit = self.fields.pop('_commit')
self.fields['_commit'] = commit self.fields['_commit'] = commit

View File

@ -276,13 +276,6 @@ class BaseScript:
@classmethod @classmethod
def _get_vars(cls): def _get_vars(cls):
vars = OrderedDict() vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(cls.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(cls, name)
# Default to order of declaration on class
for name, attr in cls.__dict__.items(): for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable): if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr vars[name] = attr
@ -296,8 +289,16 @@ class BaseScript:
""" """
Return a Django form suitable for populating the context data required to run this Script. Return a Django form suitable for populating the context data required to run this Script.
""" """
vars = self._get_vars() # Create a dynamic ScriptForm subclass from script variables
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True)) fields = {
name: var.as_field() for name, var in self._get_vars().items()
}
FormClass = type('ScriptForm', (ScriptForm,), fields)
form = FormClass(data, files, initial=initial)
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
return form return form

View File

@ -431,7 +431,6 @@ class ScriptView(ObjectPermissionRequiredMixin, View):
raise Http404 raise Http404
def get(self, request, module, name): def get(self, request, module, name):
script = self._get_script(module, name) script = self._get_script(module, name)
form = script.as_form(initial=request.GET) form = script.as_form(initial=request.GET)

View File

@ -5,7 +5,6 @@ from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
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.response import Response from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
@ -74,12 +73,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilterSet filterset_class = filters.PrefixFilterSet
@swagger_auto_schema( @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
methods=['get', 'post'], @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
responses={
200: serializers.AvailablePrefixSerializer(many=True),
}
)
@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']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None): def available_prefixes(self, request, pk=None):
@ -94,10 +89,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
if request.method == 'POST': if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_prefix'):
raise PermissionDenied()
# Validate Requested Prefixes' length # Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer( serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data], data=request.data if isinstance(request.data, list) else [request.data],
@ -158,12 +149,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema( @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
methods=['get', 'post'], @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
responses={ request_body=serializers.AvailableIPSerializer(many=False))
200: serializers.AvailableIPSerializer(many=True),
}
)
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all()) @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None): def available_ips(self, request, pk=None):
@ -180,10 +168,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
# Create the next available IP within the prefix # Create the next available IP within the prefix
if request.method == 'POST': if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_ipaddress'):
raise PermissionDenied()
# Normalize to a list of objects # Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data] requested_ips = request.data if isinstance(request.data, list) else [request.data]

View File

@ -681,11 +681,14 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False, required=False,
label='VRF' label='VRF'
) )
tags = TagField(
required=False
)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),

View File

@ -68,6 +68,11 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'], # ['John Doe', 'jdoe@example.com'],
] ]
# URL schemes that are allowed within links in NetBox
ALLOWED_URL_SCHEMES = (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
)
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same # Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. # content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = '' BANNER_TOP = ''

View File

@ -58,6 +58,9 @@ SECRET_KEY = getattr(configuration, 'SECRET_KEY')
# Set optional parameters # Set optional parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
))
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')

View File

@ -9,12 +9,12 @@
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if table.rows %} {% if table.rows %}
{% if edit_url %} {% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url edit_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button> </button>
{% endif %} {% endif %}
{% if delete_url %} {% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger"> <button type="submit" name="_delete" formaction="{% url delete_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button> </button>
{% endif %} {% endif %}

View File

@ -3,20 +3,22 @@
{% block form %} {% block form %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div> <div class="panel-heading"><strong>Rack Reservation</strong></div>
<div class="panel-body"> <div class="panel-body">
<div class="form-group"> {% render_field form.site %}
<label class="col-md-3 control-label">Rack</label> {% render_field form.rack_group %}
<div class="col-md-9"> {% render_field form.rack %}
<p class="form-control-static">{{ obj.rack }}</p>
</div>
</div>
{% render_field form.units %} {% render_field form.units %}
{% render_field form.user %} {% render_field form.user %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenant Assignment</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -70,6 +70,7 @@
<li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}> <li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
{% if perms.dcim.add_rackreservation %} {% if perms.dcim.add_rackreservation %}
<div class="buttons pull-right"> <div class="buttons pull-right">
<a href="{% url 'dcim:rackreservation_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a> <a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div> </div>
{% endif %} {% endif %}

View File

@ -26,6 +26,12 @@
{% render_field model_form.tenant %} {% render_field model_form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field model_form.tags %}
</div>
</div>
{% if model_form.custom_fields %} {% if model_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

@ -64,7 +64,7 @@
<li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a> <a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
</li> </li>
{% if perms.ipam.view_ipaddress %} {% if perms.ipam.view_ipaddress and prefix.status != 'container' %}
<li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a> <a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
</li> </li>

View File

@ -7,6 +7,7 @@ import django_filters
import yaml import yaml
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.forms import SimpleArrayField
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count from django.db.models import Count
@ -243,24 +244,11 @@ class ContentTypeSelect(StaticSelect2):
option_template_name = 'widgets/select_contenttype.html' option_template_name = 'widgets/select_contenttype.html'
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): class NumericArrayField(SimpleArrayField):
"""
MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
"""
def __init__(self, *args, **kwargs):
self.delimiter = kwargs.pop('delimiter', ',')
super().__init__(*args, **kwargs)
def optgroups(self, name, value, attrs=None): def to_python(self, value):
# Split the delimited string of values into a list value = ','.join([str(n) for n in parse_numeric_range(value)])
if value: return super().to_python(value)
value = value[0].split(self.delimiter)
return super().optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name):
# Condense the list of selected choices into a delimited string
data = super().value_from_datadict(data, files, name)
return self.delimiter.join(data)
class APISelect(SelectWithDisabled): class APISelect(SelectWithDisabled):
@ -661,9 +649,8 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
class LaxURLField(forms.URLField): class LaxURLField(forms.URLField):
""" """
Modifies Django's built-in URLField in two ways: Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
1) Allow any valid scheme per RFC 3986 section 3.1 (e.g. http://myserver/ is valid)
2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
""" """
default_validators = [EnhancedURLValidator()] default_validators = [EnhancedURLValidator()]

View File

@ -84,6 +84,10 @@ class BaseTable(tables.Table):
return [name for name in self.sequence if self.columns[name].visible] return [name for name in self.sequence if self.columns[name].visible]
#
# Table columns
#
class ToggleColumn(tables.CheckBoxColumn): class ToggleColumn(tables.CheckBoxColumn):
""" """
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@ -129,6 +133,19 @@ class ColorColumn(tables.Column):
) )
class ColoredLabelColumn(tables.TemplateColumn):
"""
Render a colored label (e.g. for DeviceRoles).
"""
template_code = """
{% load helpers %}
{% if value %}<label class="label" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
class TagColumn(tables.TemplateColumn): class TagColumn(tables.TemplateColumn):
""" """
Display a list of tags assigned to the object. Display a list of tags assigned to the object.

View File

@ -10,7 +10,6 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
from utilities.choices import unpack_grouped_choices
from utilities.utils import foreground_color from utilities.utils import foreground_color
register = template.Library() register = template.Library()
@ -39,6 +38,11 @@ def render_markdown(value):
# Strip HTML tags # Strip HTML tags
value = strip_tags(value) value = strip_tags(value)
# Sanitize Markdown links
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
# Render Markdown # Render Markdown
html = markdown(value, extensions=['fenced_code', 'tables']) html = markdown(value, extensions=['fenced_code', 'tables'])

View File

@ -1,5 +1,6 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ForeignKey, ManyToManyField from django.db.models import ForeignKey, ManyToManyField
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
@ -64,7 +65,6 @@ class TestCase(_TestCase):
""" """
Compare a model instance to a dictionary, checking that its attribute values match those specified Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary. in the dictionary.
:instance: Python object instance :instance: Python object instance
:data: Dictionary of test data used to define the instance :data: Dictionary of test data used to define the instance
:api: Set to True is the data is a JSON representation of the instance :api: Set to True is the data is a JSON representation of the instance
@ -95,6 +95,12 @@ class TestCase(_TestCase):
elif type(value) is IPNetwork: elif type(value) is IPNetwork:
model_dict[key] = str(value) model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
# Omit any dictionary keys which are not instance attributes # Omit any dictionary keys which are not instance attributes
relevant_data = { relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k) k: v for k, v in data.items() if hasattr(instance, k)

View File

@ -1,31 +1,24 @@
import re import re
from django.conf import settings
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
class EnhancedURLValidator(URLValidator): class EnhancedURLValidator(URLValidator):
""" """
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension. Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension and enforce allowed
schemes specified in the configuration.
""" """
class AnyURLScheme(object):
"""
A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
"""
def __contains__(self, item):
if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
return False
return True
fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re
host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re] host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re]
regex = _lazy_re_compile( regex = _lazy_re_compile(
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (previously enforced by AnyURLScheme or schemes kwarg) r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (enforced separately)
r'(?:\S+(?::\S*)?@)?' # HTTP basic authentication r'(?:\S+(?::\S*)?@)?' # HTTP basic authentication
r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname
r'(?::\d{2,5})?' # Port number r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE) r'\Z', re.IGNORECASE)
schemes = AnyURLScheme() schemes = settings.ALLOWED_URL_SCHEMES
class ExclusionValidator(BaseValidator): class ExclusionValidator(BaseValidator):

View File

@ -944,6 +944,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# TODO: Find a better way to accomplish this # TODO: Find a better way to accomplish this
if 'device' in request.GET: if 'device' in request.GET:
initial_data['device'] = request.GET.get('device') initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
form = self.form(model, initial=initial_data) form = self.form(model, initial=initial_data)

View File

@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, TagColumn, ToggleColumn from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """ CLUSTERTYPE_ACTIONS = """
@ -28,10 +28,6 @@ VIRTUALMACHINE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span> <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
""" """
VIRTUALMACHINE_ROLE = """
{% if record.role %}<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
"""
VIRTUALMACHINE_PRIMARY_IP = """ VIRTUALMACHINE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }} {{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %} {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@ -132,9 +128,7 @@ class VirtualMachineTable(BaseTable):
viewname='virtualization:cluster', viewname='virtualization:cluster',
args=[Accessor('cluster.pk')] args=[Accessor('cluster.pk')]
) )
role = tables.TemplateColumn( role = ColoredLabelColumn()
template_code=VIRTUALMACHINE_ROLE
)
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=COL_TENANT template_code=COL_TENANT
) )