1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'feature' into 9102-cabling

This commit is contained in:
jeremystretch
2022-06-27 12:12:34 -04:00
106 changed files with 1385 additions and 353 deletions

View File

@@ -196,6 +196,7 @@ class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = NestedSiteSerializer()
parent = NestedLocationSerializer(required=False, allow_null=True)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
@@ -203,8 +204,8 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta:
model = Location
fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
@@ -297,7 +298,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
)
margin_width = serializers.IntegerField(
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
)
exclude = serializers.IntegerField(
required=False,
@@ -854,6 +858,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -878,10 +884,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
'cable', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
'wireless_lans', 'vrf', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
def validate(self, data):

View File

@@ -215,6 +215,14 @@ class RackViewSet(NetBoxModelViewSet):
data = serializer.validated_data
if data['render'] == 'svg':
# Determine attributes for highlighting devices (if any)
highlight_params = []
for param in request.GET.getlist('highlight'):
try:
highlight_params.append(param.split(':', 1))
except ValueError:
pass
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(
face=data['face'],
@@ -223,7 +231,8 @@ class RackViewSet(NetBoxModelViewSet):
unit_height=data['unit_height'],
legend_width=data['legend_width'],
include_images=data['include_images'],
base_url=request.build_absolute_uri('/')
base_url=request.build_absolute_uri('/'),
highlight_params=highlight_params
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')

View File

@@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
]
#
# Locations
#
class LocationStatusChoices(ChoiceSet):
key = 'Location.status'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
STATUS_ACTIVE = 'active'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_RETIRED = 'retired'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_RETIRED, 'Retired', 'red'),
]
#
# Racks
#
@@ -1003,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet):
)
class InterfacePoEModeChoices(ChoiceSet):
MODE_PD = 'pd'
MODE_PSE = 'pse'
CHOICES = (
(MODE_PD, 'Powered device (PD)'),
(MODE_PSE, 'Power sourcing equipment (PSE)'),
)
class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt'
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
CHOICES = (
(
'IEEE Standard',
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'),
)
),
(
'Passive',
(
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
)
),
)
#
# FrontPorts/RearPorts
#

View File

@@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
#

View File

@@ -217,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
to_field_name='slug',
label='Location (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=LocationStatusChoices,
null_value=None
)
class Meta:
model = Location
fields = ['id', 'name', 'slug', 'description']
fields = ['id', 'name', 'slug', 'status', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -1239,6 +1243,12 @@ class InterfaceFilterSet(
)
mac_address = MultiValueMACAddressFilter()
wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
@@ -1272,8 +1282,8 @@ class InterfaceFilterSet(
class Meta:
model = Interface
fields = [
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
]
def filter_device(self, queryset, name, value):

View File

@@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
]),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
)

View File

@@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
'site_id': '$site'
}
)
status = forms.ChoiceField(
choices=add_blank_choice(LocationStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
model = Location
fieldsets = (
(None, ('site', 'parent', 'tenant', 'description')),
(None, ('site', 'parent', 'status', 'tenant', 'description')),
)
nullable_fields = ('parent', 'tenant', 'description')
@@ -1063,6 +1069,18 @@ class InterfaceBulkEditForm(
widget=BulkEditNullBooleanSelect,
label='Management only'
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect()
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect()
)
mark_connected = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
@@ -1105,14 +1123,15 @@ class InterfaceBulkEditForm(
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
'tagged_vlans', 'vrf',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
)
def __init__(self, *args, **kwargs):

View File

@@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
'invalid_choice': 'Location not found.',
}
)
status = CSVChoiceField(
choices=LocationStatusChoices,
help_text='Operational status'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
@@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
class Meta:
model = Location
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
class RackRoleCSVForm(NetBoxModelCSVForm):
@@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
choices=InterfaceDuplexChoices,
required=False
)
poe_mode = CSVChoiceField(
choices=InterfacePoEModeChoices,
required=False,
help_text='PoE mode'
)
poe_type = CSVChoiceField(
choices=InterfacePoETypeChoices,
required=False,
help_text='PoE type'
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
@@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power',
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
)
def __init__(self, data=None, *args, **kwargs):

View File

@@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location
fieldsets = (
(None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
@@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
},
label=_('Parent')
)
status = MultipleChoiceField(
choices=LocationStatusChoices,
required=False
)
tag = TagFilterField(model)
@@ -969,6 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
)
@@ -1009,6 +1014,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
label='WWN'
)
poe_mode = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False
)
rf_role = MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,

View File

@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Location', (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Location
fields = (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
'tags',
)
widgets = {
'status': StaticSelect(),
}
class RackRoleForm(NetBoxModelForm):
@@ -1314,6 +1318,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
@@ -1324,14 +1329,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
model = Interface
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
'vrf', 'tags',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
'duplex': StaticSelect(),
'mode': StaticSelect(),
'rf_role': StaticSelect(),

View File

@@ -234,6 +234,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
exclude = ('_path',)
filterset_class = filtersets.InterfaceFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
def resolve_mode(self, info):
return self.mode or None

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.0.5 on 2022-06-22 00:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0154_half_height_rack_units'),
]
operations = [
migrations.AddField(
model_name='interface',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interface',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.0.5 on 2022-06-22 17:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0155_interface_poe_mode_type'),
]
operations = [
migrations.AddField(
model_name='location',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -6,7 +6,7 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0154_half_height_rack_units'),
('dcim', '0156_location_status'),
]
operations = [

View File

@@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('dcim', '0155_new_cabling_models'),
('dcim', '0157_new_cabling_models'),
]
operations = [

View File

@@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('dcim', '0156_populate_cable_terminations'),
('dcim', '0158_populate_cable_terminations'),
]
operations = [

View File

@@ -30,8 +30,8 @@ def populate_cable_terminations(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('circuits', '0036_new_cabling_models'),
('dcim', '0157_populate_cable_paths'),
('circuits', '0037_new_cabling_models'),
('dcim', '0159_populate_cable_paths'),
]
operations = [

View File

@@ -4,7 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0158_populate_cable_ends'),
('dcim', '0160_populate_cable_ends'),
]
operations = [

View File

@@ -575,6 +575,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
validators=(MaxValueValidator(127),),
verbose_name='Transmit power (dBm)'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
on_delete=models.SET_NULL,
@@ -623,7 +635,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
related_query_name='+'
)
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
class Meta:
ordering = ('device', CollateAsChar('_name'))
@@ -711,6 +723,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
f"of virtual chassis {self.device.virtual_chassis}."
})
# PoE validation
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
'poe_mode': "Virtual interfaces cannot have a PoE mode."
})
if self.poe_type and self.is_virtual:
raise ValidationError({
'poe_type': "Virtual interfaces cannot have a PoE type."
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': "Must specify PoE mode when designating a PoE type."
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces

View File

@@ -367,9 +367,11 @@ class Rack(NetBoxModel):
user=None,
unit_width=None,
unit_height=None,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
include_images=True,
base_url=None
base_url=None,
highlight_params=None
):
"""
Return an SVG of the rack elevation
@@ -381,6 +383,7 @@ class Rack(NetBoxModel):
: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 margin_width: Width of the rigth-hand margin, in pixels
:param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
@@ -389,9 +392,11 @@ class Rack(NetBoxModel):
unit_width=unit_width,
unit_height=unit_height,
legend_width=legend_width,
margin_width=margin_width,
user=user,
include_images=include_images,
base_url=base_url
base_url=base_url,
highlight_params=highlight_params
)
return elevation.render(face)

View File

@@ -341,6 +341,11 @@ class Location(NestedGroupModel):
null=True,
db_index=True
)
status = models.CharField(
max_length=50,
choices=LocationStatusChoices,
default=LocationStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -367,7 +372,7 @@ class Location(NestedGroupModel):
to='extras.ImageAttachment'
)
clone_fields = ['site', 'parent', 'tenant', 'description']
clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
class Meta:
ordering = ['site', 'name']
@@ -409,6 +414,9 @@ class Location(NestedGroupModel):
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
def get_status_color(self):
return LocationStatusChoices.colors.get(self.status)
def clean(self):
super().clean()

View File

@@ -7,12 +7,13 @@ from svgwrite.shapes import Rect
from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
from django.urls import reverse
from django.utils.http import urlencode
from netbox.config import get_config
from utilities.utils import foreground_color
from dcim.choices import DeviceFaceChoices
from utilities.utils import foreground_color, array_to_ranges
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
@@ -51,12 +52,17 @@ class RackElevationSVG:
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param unit_width: Rendered unit width, in pixels
:param unit_height: Rendered unit height, in pixels
:param legend_width: Legend width, in pixels (where the unit labels appear)
:param margin_width: Margin width, in pixels (where reservations appear)
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
:param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
"""
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True,
base_url=None):
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
include_images=True, base_url=None, highlight_params=None):
self.rack = rack
self.include_images = include_images
self.base_url = base_url.rstrip('/') if base_url is not None else ''
@@ -65,7 +71,8 @@ class RackElevationSVG:
config = get_config()
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
@@ -73,6 +80,17 @@ class RackElevationSVG:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
# Determine device(s) to highlight within the elevation (if any)
self.highlight_devices = []
if highlight_params:
q = Q()
for k, v in highlight_params:
q |= Q(**{k: v})
try:
self.highlight_devices = permitted_devices.filter(q)
except FieldError:
pass
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = LinearGradient(
@@ -91,7 +109,7 @@ class RackElevationSVG:
drawing.defs.add(gradient)
def _setup_drawing(self):
width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2
width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
drawing = svgwrite.Drawing(size=(width, height))
@@ -100,6 +118,7 @@ class RackElevationSVG:
drawing.defs.add(drawing.style(css_file.read()))
# Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
@@ -121,40 +140,44 @@ class RackElevationSVG:
def _draw_device(self, device, coords, size, color=None, image=None):
name = get_device_name(device)
description = get_device_description(device)
text_color = f'#{foreground_color(color)}' if color else '#000000'
text_coords = (
coords[0] + size[0] / 2,
coords[1] + size[1] / 2
)
text_color = f'#{foreground_color(color)}' if color else '#000000'
# Determine whether highlighting is in use, and if so, whether to shade this device
is_shaded = self.highlight_devices and device not in self.highlight_devices
css_extra = ' shaded' if is_shaded else ''
# Create hyperlink element
link = Hyperlink(
href='{}{}'.format(
self.base_url,
reverse('dcim:device', kwargs={'pk': device.pk})
),
target='_blank',
)
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
link.set_desc(description)
# Add rect element to hyperlink
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot'))
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else:
link.add(Rect(coords, size, class_='slot blocked'))
link.add(Text(name, insert=text_coords, fill=text_color))
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
# Embed device type image if provided
if self.include_images and image:
image = Image(
href='{}{}'.format(self.base_url, image.url),
href=f'{self.base_url}{image.url}',
insert=coords,
size=size,
class_='device-image'
class_=f'device-image{css_extra}'
)
image.fit(scale='slice')
link.add(image)
link.add(Text(name, insert=text_coords, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label'))
link.add(
Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
class_=f'device-image-label{css_extra}')
)
link.add(
Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
)
self.drawing.add(link)
@@ -198,6 +221,29 @@ class RackElevationSVG:
Text(str(unit), position_coordinates, class_='unit')
)
def draw_margin(self):
"""
Draw any rack reservations in the right-hand margin alongside the rack elevation.
"""
for reservation in self.rack.reservations.all():
for segment in array_to_ranges(reservation.units):
u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = (
self.margin_width,
u_height * self.unit_height
)
link = Hyperlink(
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
target='_blank'
)
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
)
self.drawing.add(link)
def draw_background(self, face):
"""
Draw the rack unit placeholders which form the "background" of the rack elevation.
@@ -261,16 +307,12 @@ class RackElevationSVG:
# Initialize the drawing
self.drawing = self._setup_drawing()
# Draw the empty rack & legend
# Draw the empty rack, legend, and margin
self.draw_legend()
self.draw_background(face)
self.draw_margin()
# Draw the opposite rack face first, then the near face
if face == DeviceFaceChoices.FACE_REAR:
opposite_face = DeviceFaceChoices.FACE_FRONT
else:
opposite_face = DeviceFaceChoices.FACE_REAR
# self.draw_face(opposite_face, opposite=True)
# Draw the rack face
self.draw_face(face)
# Draw the rack border last

View File

@@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'created', 'last_updated',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@@ -126,6 +126,7 @@ class LocationTable(NetBoxTable):
site = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
@@ -150,7 +151,7 @@ class LocationTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug',
'contacts', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')

View File

@@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites)
parent_locations = (
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
cls.create_data = [
{
@@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'slug': 'test-location-4',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 5',
'slug': 'test-location-5',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 6',
'slug': 'test-location-6',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
]
@@ -1507,6 +1510,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'speed': 1000000,
'duplex': 'full',
'vrf': vrfs[0].pk,
'poe_mode': InterfacePoEModeChoices.MODE_PD,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},

View File

@@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
location.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
)
for location in locations:
location.save()
@@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['location-1', 'location-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2540,14 +2544,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
Interface(
device=devices[0],
module=modules[0],
name='Interface 1',
label='A',
type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True,
mgmt_only=True,
mtu=100,
mode=InterfaceModeChoices.MODE_ACCESS,
mac_address='00-00-00-00-00-01',
description='First',
vrf=vrfs[0],
speed=1000000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[1],
module=modules[1],
name='Interface 2',
label='B',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
enabled=True,
mgmt_only=True,
mtu=200,
mode=InterfaceModeChoices.MODE_TAGGED,
mac_address='00-00-00-00-00-02',
description='Second',
vrf=vrfs[1],
speed=1000000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[2],
module=modules[2],
name='Interface 3',
label='C',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
enabled=False,
mgmt_only=False,
mtu=300,
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
mac_address='00-00-00-00-00-03',
description='Third',
vrf=vrfs[2],
speed=100000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 4',
label='D',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40,
speed=100000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 5',
label='E',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 6',
label='F',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=False,
mgmt_only=False,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 7',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412,
rf_channel_width=22
),
Interface(
device=devices[3],
name='Interface 8',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
),
)
Interface.objects.bulk_create(interfaces)
@@ -2594,6 +2693,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
locations = (
Location(name='Location 1', slug='location-1', site=site, tenant=tenant),
Location(name='Location 2', slug='location-2', site=site, tenant=tenant),
Location(name='Location 3', slug='location-3', site=site, tenant=tenant),
Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
)
for location in locations:
location.save()
@@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'name': 'Location X',
'slug': 'location-x',
'site': site.pk,
'status': LocationStatusChoices.STATUS_PLANNED,
'tenant': tenant.pk,
'description': 'A new location',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"site,tenant,name,slug,description",
"Site 1,Tenant 1,Location 4,location-4,Fourth location",
"Site 1,Tenant 1,Location 5,location-5,Fifth location",
"Site 1,Tenant 1,Location 6,location-6,Sixth location",
"site,tenant,name,slug,status,description",
"Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
"Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
)
cls.bulk_edit_data = {
@@ -2204,6 +2205,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
@@ -2225,6 +2228,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'duplex': 'half',
'mgmt_only': True,
'description': 'A front port',
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
@@ -2244,6 +2249,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'duplex': 'full',
'mgmt_only': True,
'description': 'New description',
'poe_mode': InterfacePoEModeChoices.MODE_PD,
'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT,
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'untagged_vlan': vlans[0].pk,
@@ -2252,10 +2259,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
}
cls.csv_data = (
f"device,name,type,vrf.pk",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
f"device,name,type,vrf.pk,poe_mode,poe_type",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

View File

@@ -639,6 +639,11 @@ class RackView(generic.ObjectView):
device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count()
# Determine any additional parameters to pass when embedding the rack elevations
svg_extra = '&'.join([
f'highlight=id:{pk}' for pk in request.GET.getlist('device')
])
return {
'device_count': device_count,
'reservations': reservations,
@@ -646,6 +651,7 @@ class RackView(generic.ObjectView):
'nonracked_devices': nonracked_devices,
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
}