Merge main into feature

This commit is contained in:
Jeremy Stretch
2025-04-10 17:17:21 -04:00
parent bb5057c063
commit fc0acb020f
197 changed files with 63438 additions and 53007 deletions

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.prefetch import GenericPrefetch
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
@@ -448,7 +449,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'_path', 'cable__terminations',
GenericPrefetch(
"cable__terminations__termination",
[
Interface.objects.select_related("device", "cable"),
],
),
GenericPrefetch(
"_path__path_objects",
[
Interface.objects.select_related("device", "cable"),
],
),
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
'ip_addresses', # Referenced by Interface.count_ipaddresses()
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()

View File

@@ -989,6 +989,7 @@ class InterfaceTypeChoices(ChoiceSet):
# Coaxial
TYPE_DOCSIS = 'docsis'
TYPE_MOCA = 'moca'
# PON
TYPE_BPON = 'bpon'
@@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
_('Coaxial'),
(
(TYPE_DOCSIS, 'DOCSIS'),
(TYPE_MOCA, 'MoCA'),
)
),
(
@@ -1348,6 +1350,9 @@ class PortTypeChoices(ChoiceSet):
TYPE_SC_UPC = 'sc-upc'
TYPE_SC_APC = 'sc-apc'
TYPE_FC = 'fc'
TYPE_FC_PC = 'fc-pc'
TYPE_FC_UPC = 'fc-upc'
TYPE_FC_APC = 'fc-apc'
TYPE_LC = 'lc'
TYPE_LC_PC = 'lc-pc'
TYPE_LC_UPC = 'lc-upc'
@@ -1408,6 +1413,9 @@ class PortTypeChoices(ChoiceSet):
_('Fiber Optic'),
(
(TYPE_FC, 'FC'),
(TYPE_FC_PC, 'FC/PC'),
(TYPE_FC_UPC, 'FC/UPC'),
(TYPE_FC_APC, 'FC/APC'),
(TYPE_LC, 'LC'),
(TYPE_LC_PC, 'LC/PC'),
(TYPE_LC_UPC, 'LC/UPC'),

View File

@@ -0,0 +1,2 @@
class UnsupportedCablePath(Exception):
pass

View File

@@ -1246,6 +1246,7 @@ class DeviceFilterSet(
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(virtual_chassis__name__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |

View File

@@ -1458,7 +1458,7 @@ class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'wireless_lans'
'wireless_lans', 'vlan_translation_policy'
])
):
enabled = forms.NullBooleanField(
@@ -1611,7 +1611,9 @@ class InterfaceBulkEditForm(
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
),
FieldSet(
TabbedGroups(
FieldSet('tagged_vlans', name=_('Assignment')),
@@ -1626,7 +1628,7 @@ class InterfaceBulkEditForm(
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans'
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
)
def __init__(self, *args, **kwargs):

View File

@@ -1192,27 +1192,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
def clean(self):
super().clean()
cleaned_data = self.cleaned_data
component_type = cleaned_data.get('component_type')
component_name = cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
if component_type:
if device is None:
cleaned_data.pop('component_type', None)
if component_name is None:
cleaned_data.pop('component_type', None)
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
)
if all([device, component_name]):
try:
model = component_type.model_class()
self.instance.component = model.objects.get(device=device, name=component_name)
except ObjectDoesNotExist:
cleaned_data.pop('component_type', None)
cleaned_data.pop('component_name', None)
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
)
)
else:
cleaned_data.pop('component_type', None)
if not component_name:
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
else:
if component_name:
raise forms.ValidationError(
_("Component type must be specified when component name is specified")
)
return cleaned_data
#

View File

@@ -304,7 +304,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)

View File

@@ -153,6 +153,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
# positions
@@ -302,6 +303,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
frontport_count = len(self.cleaned_data['name'])

View File

@@ -30,6 +30,7 @@ class PathEndpointMixin:
connected_endpoints: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821

View File

@@ -471,7 +471,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
)
class InventoryItemType(ComponentType):
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]

View File

@@ -8,7 +8,7 @@ import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0204_device_role_rebuild'),
('extras', '0125_exporttemplate_file_name'),
('extras', '0126_exporttemplate_file_name'),
]
operations = [

View File

@@ -1,5 +1,4 @@
import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
@@ -15,7 +14,8 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.fields import ColorField
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort, PathEndpoint
@@ -26,6 +26,7 @@ __all__ = (
'CableTermination',
)
from ..exceptions import UnsupportedCablePath
trace_paths = Signal()
@@ -236,8 +237,10 @@ class Cable(PrimaryModel):
for termination in self.b_terminations:
if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
trace_paths.send(Cable, instance=self, created=_created)
try:
trace_paths.send(Cable, instance=self, created=_created)
except UnsupportedCablePath as e:
raise AbortRequest(e)
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
@@ -486,13 +489,16 @@ class CablePath(models.Model):
return ObjectType.objects.get_for_id(ct_id)
@property
def path_objects(self):
"""
Cache and return the complete path as lists of objects, derived from their annotation within the path.
"""
if not hasattr(self, '_path_objects'):
self._path_objects = self._get_path()
return self._path_objects
def _path_decompiled(self):
res = []
for step in self.path:
nodes = []
for node in step:
nodes.append(decompile_path_node(node))
res.append(nodes)
return res
path_objects = GenericArrayForeignKey("_path_decompiled")
@property
def origins(self):
@@ -527,8 +533,8 @@ class CablePath(models.Model):
return None
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
path = []
position_stack = []
@@ -539,12 +545,13 @@ class CablePath(models.Model):
while terminations:
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
# All mid-span terminations must all be attached to the same device
if not isinstance(terminations[0], PathEndpoint):
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
if (not isinstance(terminations[0], PathEndpoint) and not
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
@@ -567,8 +574,10 @@ class CablePath(models.Model):
return None
# Otherwise, halt the trace if no link exists
break
assert all(type(link) in (Cable, WirelessLink) for link in links)
assert all(isinstance(link, type(links[0])) for link in links)
if not all(type(link) in (Cable, WirelessLink) for link in links):
raise UnsupportedCablePath(_("All links must be cable or wireless"))
if not all(isinstance(link, type(links[0])) for link in links):
raise UnsupportedCablePath(_("All links must match first link type"))
# Step 3: Record asymmetric paths as split
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
@@ -649,14 +658,18 @@ class CablePath(models.Model):
positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
assert len(remote_terminations) == len(positions)
if len(remote_terminations) != len(positions):
raise UnsupportedCablePath(
_("All positions counts within the path on opposite ends of links must match")
)
# Get our front ports
q_filter = Q()
for rt in remote_terminations:
position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
assert q_filter is not Q()
if q_filter is Q():
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position
elif position_stack:
@@ -742,42 +755,6 @@ class CablePath(models.Model):
self.delete()
retrace.alters_data = True
def _get_path(self):
"""
Return the path as a list of prefetched objects.
"""
# Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list)
for node in self._nodes:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
prefetched = {}
for ct_id, object_ids in to_prefetch.items():
model_class = ObjectType.objects.get_for_id(ct_id).model_class()
queryset = model_class.objects.filter(pk__in=object_ids)
if hasattr(model_class, 'device'):
queryset = queryset.prefetch_related('device')
prefetched[ct_id] = {
obj.id: obj for obj in queryset
}
# Replicate the path using the prefetched objects.
path = []
for step in self.path:
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
try:
nodes.append(prefetched[ct_id][object_id])
except KeyError:
# Ignore stale (deleted) object IDs
pass
path.append(nodes)
return path
def get_cable_ids(self):
"""
Return all Cable IDs within the path.

View File

@@ -184,8 +184,11 @@ class CabledObjectModel(models.Model):
@cached_property
def link_peers(self):
if self.cable:
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
return [peer.termination for peer in peers]
return [
peer.termination
for peer in self.cable.terminations.all()
if peer.cable_end != self.cable_end
]
return []
@property

View File

@@ -690,14 +690,10 @@ class Device(
verbose_name_plural = _('devices')
def __str__(self):
if self.name and self.asset_tag:
return f'{self.name} ({self.asset_tag})'
elif self.name:
return self.name
elif self.virtual_chassis and self.asset_tag:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
if self.label and self.asset_tag:
return f'{self.label} ({self.asset_tag})'
elif self.label:
return self.label
elif self.device_type and self.asset_tag:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type:
@@ -961,14 +957,22 @@ class Device(
device.location = self.location
device.save()
@property
def label(self):
"""
Return the device name if set; otherwise return a generated name if available.
"""
if self.name:
return self.name
if self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position}'
@property
def identifier(self):
"""
Return the device name if set; otherwise return the Device's primary key as {pk}
"""
if self.name is not None:
return self.name
return '{{{}}}'.format(self.pk)
return self.label or '{{{}}}'.format(self.pk)
@property
def primary_ip(self):
@@ -1268,7 +1272,10 @@ class MACAddress(PrimaryModel):
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
if original_assigned_object.primary_mac_address:
if (
original_assigned_object.primary_mac_address
and original_assigned_object.primary_mac_address.pk == self.pk
):
if not assigned_object:
raise ValidationError(
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object")

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import *
from dcim.constants import MODULE_TOKEN
from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel
@@ -341,6 +342,7 @@ class Module(PrimaryModel, ConfigContextModel):
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
instance.save()
update_fields = ['module']

View File

@@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
('asset_tag', 50),
('serial', 60),
('name', 100),
('virtual_chassis', 200),
('description', 500),
('comments', 5000),
)

View File

@@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
def get_device_name(device):
if device.virtual_chassis:
name = f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
name = device.name
if device.label:
name = device.label
else:
name = str(device.device_type)
if device.devicebay_count:

View File

@@ -144,7 +144,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),
template_code=DEVICE_LINK,
linkify=True
linkify=True,
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -678,7 +678,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
'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', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
racktype_count = columns.LinkedCountColumn(
viewname='dcim:racktype_list',
url_params={'manufacturer_id': 'pk'},
verbose_name=_('Rack Types')
)
devicetype_count = columns.LinkedCountColumn(
viewname='dcim:devicetype_list',
url_params={'manufacturer_id': 'pk'},
@@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug',
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count', 'description', 'slug',
)

View File

@@ -100,7 +100,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name=_('ASNs')
)
asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name=_('ASN Count')
@@ -153,6 +152,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name=_('Devices')
)
vlangroup_count = columns.LinkedCountColumn(
viewname='ipam:vlangroup_list',
url_params={'location': 'pk'},
verbose_name=_('VLAN Groups')
)
tags = columns.TagColumn(
url_name='dcim:location_list'
)
@@ -168,7 +172,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'vlangroup_count',
)
default_columns = (
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
'description'
)

View File

@@ -35,7 +35,7 @@ WEIGHT = """
"""
DEVICE_LINK = """
{{ value|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
{{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
"""
DEVICEBAY_STATUS = """
@@ -164,8 +164,8 @@ CONSOLEPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -177,7 +177,7 @@ CONSOLEPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -214,8 +214,8 @@ CONSOLESERVERPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -227,7 +227,7 @@ CONSOLESERVERPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -264,8 +264,8 @@ POWERPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -276,7 +276,7 @@ POWERPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -313,14 +313,14 @@ POWEROUTLET_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
@@ -407,8 +407,8 @@ INTERFACE_BUTTONS = """
</a>
{% endif %}
{% elif record.is_wired and perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
@@ -422,7 +422,7 @@ INTERFACE_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
@@ -464,8 +464,8 @@ FRONTPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -481,7 +481,7 @@ FRONTPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
@@ -519,8 +519,8 @@ REARPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -536,7 +536,7 @@ REARPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""

View File

@@ -1,6 +1,6 @@
import json
from django.test import override_settings
from django.test import override_settings, tag
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status
@@ -1979,6 +1979,27 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression') # Issue #18991
def test_front_port_paths(self):
device = Device.objects.first()
rear_port = RearPort.objects.create(
device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
)
interface1 = Interface.objects.create(device=device, name='Interface 1')
front_port = FrontPort.objects.create(
device=device,
name='Rear Port 10',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port,
)
Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort
@@ -2022,6 +2043,23 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression') # Issue #18991
def test_rear_port_paths(self):
device = Device.objects.first()
interface1 = Interface.objects.create(device=device, name='Interface 1')
rear_port = RearPort.objects.create(
device=device,
name='Rear Port 10',
type=PortTypeChoices.TYPE_8P8C,
)
Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay

View File

@@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
from dcim.models import *
from dcim.svg import CableTraceSVG
from dcim.utils import object_to_path_node
from utilities.exceptions import AbortRequest
class CablePathTestCase(TestCase):
@@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
b_terminations=[frontport1, frontport3],
label='C1'
)
with self.assertRaises(AssertionError):
with self.assertRaises(AbortRequest):
cable1.save()
self.assertPathDoesNotExist(
@@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
label='C3'
)
with self.assertRaises(AssertionError):
with self.assertRaises(AbortRequest):
cable3.save()
self.assertPathDoesNotExist(

View File

@@ -1,7 +1,8 @@
from django.test import TestCase
from dcim.choices import (
DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices
DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
PowerOutletStatusChoices,
)
from dcim.forms import *
from dcim.models import *
@@ -170,6 +171,51 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors)
class FrontPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.device = create_test_device('Panel Device 1')
cls.rear_ports = (
RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
)
RearPort.objects.bulk_create(cls.rear_ports)
def test_front_port_label_count_valid(self):
"""
Test that generating an equal number of names and labels passes form validation.
"""
front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-4]',
'label': 'Port[1-4]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
}
form = FrontPortCreateForm(front_port_data)
self.assertTrue(form.is_valid())
def test_front_port_label_count_mismatch(self):
"""
Check that attempting to generate a differing number of names and labels results in a validation error.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-4]',
'label': 'Port[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn('label', form.errors)
class InterfaceTestCase(TestCase):
@classmethod

View File

@@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test import tag, TestCase
from circuits.models import *
from core.models import ObjectType
@@ -12,6 +12,43 @@ from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class MACAddressTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
device = Device.objects.create(
name='Device 1', device_type=device_type, role=device_role, site=site,
)
cls.interface = Interface.objects.create(
device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
cls.interface.primary_mac_address = cls.mac_a
cls.interface.save()
@tag('regression')
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
self.mac_a.assigned_object = None
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
self.mac_a.clean()
@tag('regression')
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
self.mac_b.assigned_object = None
self.mac_b.clean()
class LocationTestCase(TestCase):
def test_change_location_site(self):
@@ -592,6 +629,32 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
def test_device_label(self):
device1 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
)
self.assertEqual(device1.label, None)
device1.name = 'Test Device 1'
self.assertEqual(device1.label, 'Test Device 1')
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
device2 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
virtual_chassis=virtual_chassis,
vc_position=2,
)
self.assertEqual(device2.label, 'VC 1:2')
device2.name = 'Test Device 2'
self.assertEqual(device2.label, 'Test Device 2')
def test_device_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type)

View File

@@ -10,10 +10,9 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@@ -412,7 +411,8 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Site, 'list', path='', detail=False)
class SiteListView(generic.ObjectListView):
queryset = Site.objects.annotate(
device_count=count_related(Device, 'site')
device_count=count_related(Device, 'site'),
asn_count=count_related(ASN, 'sites')
)
filterset = filtersets.SiteFilterSet
filterset_form = forms.SiteFilterForm
@@ -489,18 +489,24 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Location, 'list', path='', detail=False)
class LocationListView(generic.ObjectListView):
queryset = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
)
Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
),
VLANGroup,
'location',
'vlangroup_count',
cumulative=True
)
filterset = filtersets.LocationFilterSet
filterset_form = forms.LocationFilterForm
table = tables.LocationTable
@@ -512,6 +518,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True)
location_content_type = ContentType.objects.get_for_model(instance)
return {
'related_models': self.get_related_models(
request,
@@ -529,6 +536,8 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type_id=location_content_type.id, scope_id=instance.id), 'location'),
),
),
}
@@ -939,6 +948,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Manufacturer, 'list', path='', detail=False)
class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate(
racktype_count=count_related(RackType, 'manufacturer'),
devicetype_count=count_related(DeviceType, 'manufacturer'),
moduletype_count=count_related(ModuleType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
@@ -2049,7 +2059,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Device, 'list', path='', detail=False)
class DeviceListView(generic.ObjectListView):
queryset = Device.objects.all()
queryset = Device.objects.select_related('virtual_chassis')
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable
@@ -2277,51 +2287,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
@register_model_view(Device, 'render-config')
class DeviceRenderConfigView(generic.ObjectView):
class DeviceRenderConfigView(ObjectRenderConfigView):
queryset = Device.objects.all()
template_name = 'dcim/device/render_config.html'
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Render Config'),
weight=2100
weight=2100,
)
def get(self, request, **kwargs):
instance = self.get_object(**kwargs)
context = self.get_extra_context(request, instance)
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
response = context['config_template'].render_to_response(context=context['context_data'])
return response
return render(request, self.get_template_name(), {
'object': instance,
'tab': self.tab,
**context,
})
def get_extra_context(self, request, instance):
# Compile context data
context_data = instance.get_config_context()
context_data.update({'device': instance})
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}
@register_model_view(Device, 'virtual-machines')
class DeviceVirtualMachinesView(generic.ObjectChildrenView):