Merge pull request #9615 from netbox-community/9102-cabling

Closes #9102: Add support for multi-termination cable ends
This commit is contained in:
Jeremy Stretch 2022-07-08 14:59:38 -04:00 committed by GitHub
commit 6c9f2734a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 3356 additions and 2203 deletions

View File

@ -6,6 +6,28 @@
* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
* Several fields on the cable API serializers have been altered to support multiple-object cable terminations:
| Old Name | Old Type | New Name | New Type |
|----------------------|----------|-----------------------|----------|
| `termination_a_type` | string | `a_terminations_type` | string |
| `termination_b_type` | string | `b_terminations_type` | string |
| `termination_a_id` | integer | _Removed_ | - |
| `termination_b_id` | integer | _Removed_ | - |
| `termination_a` | object | `a_terminations` | list |
| `termination_b` | object | `b_terminations` | list |
* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed:
| Old Name | Old Type | New Name | New Type |
|--------------------------------|----------|---------------------------------|----------|
| `link_peer` | object | `link_peers` | list |
| `link_peer_type` | string | `link_peers_type` | string |
| `connected_endpoint` | object | `connected_endpoints` | list |
| `connected_endpoint_type` | string | `connected_endpoints_type` | string |
| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean |
* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.)
### New Features
@ -19,6 +41,8 @@
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@ -55,23 +79,83 @@
### REST API Changes
* Added the following endpoints:
* `/api/dcim/cable-terminations/`
* `/api/ipam/l2vpns/`
* `/api/ipam/l2vpn-terminations/`
* circuits.Circuit
* Added optional `termination_date` field
* circuits.CircuitTermination
* Added 'custom_fields' and 'tags' fields
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added `custom_fields` and `tags` fields
* dcim.Cable
* `termination_a_type` has been renamed to `a_terminations_type`
* `termination_b_type` has been renamed to `b_terminations_type`
* `termination_a` renamed to `a_terminations` and now returns a list of objects
* `termination_b` renamed to `b_terminations` and now returns a list of objects
* `termination_a_id` has been removed
* `termination_b_id` has been removed
* dcim.ConsolePort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.ConsoleServerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Device
* The `position` field has been changed from an integer to a decimal
* dcim.DeviceType
* The `u_height` field has been changed from an integer to a decimal
* dcim.FrontPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Interface
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added the optional `poe_mode` and `poe_type` fields
* Added the `l2vpn_termination` read-only field
* dcim.Location
* Added required `status` field (default value: `active`)
* dcim.PowerOutlet
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerFeed
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Rack
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
* dcim.RearPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* extras.ConfigContext
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
* extras.CustomField

View File

@ -3,11 +3,11 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer
from dcim.api.serializers import CabledObjectSerializer
from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer
from netbox.api import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@ -98,17 +98,16 @@ class CircuitSerializer(NetBoxModelSerializer):
]
class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied', 'tags', 'custom_fields', 'created', 'last_updated',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet):
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'provider_network', 'cable'
'circuit', 'site', 'provider_network', 'cable__terminations'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filtersets.CircuitTerminationFilterSet

View File

@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet
from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
).distinct()
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet):
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSe
class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
def search(self, queryset, name, value):
if not value.strip():

View File

@ -1,4 +1,5 @@
from circuits import filtersets, models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
@ -11,7 +12,7 @@ __all__ = (
)
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
class Meta:
model = models.CircuitTermination

View File

@ -1,18 +0,0 @@
# Generated by Django 4.0.5 on 2022-06-22 18:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0035_provider_asns'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='termination_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@ -6,11 +6,15 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0076_configcontext_locations'),
('circuits', '0036_circuit_termination_date'),
('circuits', '0035_provider_asns'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='termination_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='custom_field_data',

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0160_populate_cable_ends'),
]
operations = [
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_type',
),
]

View File

@ -4,7 +4,7 @@ from django.db import models
from django.urls import reverse
from circuits.choices import *
from dcim.models import LinkTermination
from dcim.models import CabledObjectModel
from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
)
@ -149,7 +149,7 @@ class CircuitTermination(
TagsMixin,
WebhooksMixin,
ChangeLoggedModel,
LinkTermination
CabledObjectModel
):
circuit = models.ForeignKey(
to='circuits.Circuit',

View File

@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
if not raw:
peer_termination = instance.get_peer_termination()
if peer_termination:
rebuild_paths(peer_termination)
rebuild_paths([peer_termination])

View File

@ -360,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
))
CircuitTermination.objects.bulk_create(circuit_terminations)
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
def test_term_side(self):
params = {'term_side': 'A'}

View File

@ -246,7 +246,7 @@ class CircuitTerminationTestCase(
device=device,
name='Interface 1'
)
Cable(termination_a=circuittermination, termination_b=interface).save()
Cable(a_terminations=[circuittermination], b_terminations=[interface]).save()
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)

View File

@ -1,6 +1,6 @@
from django.urls import path
from dcim.views import CableCreateView, PathTraceView
from dcim.views import PathTraceView
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import *
@ -60,7 +60,6 @@ urlpatterns = [
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@ -28,58 +28,68 @@ from wireless.models import WirelessLAN
from .nested_serializers import *
class LinkTerminationSerializer(serializers.ModelSerializer):
link_peer_type = serializers.SerializerMethodField(read_only=True)
link_peer = serializers.SerializerMethodField(read_only=True)
class CabledObjectSerializer(serializers.ModelSerializer):
cable = NestedCableSerializer(read_only=True)
cable_end = serializers.CharField(read_only=True)
link_peers_type = serializers.SerializerMethodField(read_only=True)
link_peers = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
def get_link_peer_type(self, obj):
if obj._link_peer is not None:
return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
def get_link_peers_type(self, obj):
"""
Return the type of the peer link terminations, or None.
"""
if not obj.cable:
return None
if obj.link_peers:
return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_link_peer(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_link_peers(self, obj):
"""
Return the appropriate serializer for the link termination model.
"""
if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj._link_peer, context=context).data
return None
if not obj.link_peers:
return []
# Return serialized peer termination objects
serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.link_peers, context=context, many=True).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get__occupied(self, obj):
return obj._occupied
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class ConnectedEndpointsSerializer(serializers.ModelSerializer):
"""
Legacy serializer for pre-v3.3 connections
"""
connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
connected_endpoints = serializers.SerializerMethodField(read_only=True)
connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
def get_connected_endpoint_type(self, obj):
if obj._path is not None and obj._path.destination is not None:
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
return None
def get_connected_endpoints_type(self, obj):
if endpoints := obj.connected_endpoints:
return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_connected_endpoint(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_connected_endpoints(self, obj):
"""
Return the appropriate serializer for the type of connected object.
"""
if obj._path is not None and obj._path.destination is not None:
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
if endpoints := obj.connected_endpoints:
serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj._path.destination, context=context).data
return None
return serializer(endpoints, many=True, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get_connected_endpoint_reachable(self, obj):
if obj._path is not None:
return obj._path.is_active
return None
def get_connected_endpoints_reachable(self, obj):
return obj._path and obj._path.is_complete and obj._path.is_active
#
@ -684,7 +694,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -701,18 +711,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -729,18 +739,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -761,21 +771,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
allow_blank=True,
required=False
)
cable = NestedCableSerializer(
read_only=True
)
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -787,19 +794,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -825,7 +831,6 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
cable = NestedCableSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True)
wireless_lans = SerializedPKRelatedField(
queryset=WirelessLAN.objects.all(),
@ -842,9 +847,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
'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',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans',
'vrf', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
'count_fhrp_groups', '_occupied',
]
def validate(self, data):
@ -861,7 +867,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
return super().validate(data)
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -869,13 +875,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@ -891,7 +896,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@ -900,14 +905,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
)
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
@ -990,14 +994,10 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
class CableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
a_terminations_type = serializers.SerializerMethodField(read_only=True)
b_terminations_type = serializers.SerializerMethodField(read_only=True)
a_terminations = serializers.SerializerMethodField(read_only=True)
b_terminations = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@ -1005,33 +1005,46 @@ class CableSerializer(NetBoxModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'type', 'a_terminations_type', 'a_terminations', 'b_terminations_type',
'b_terminations', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields',
'created', 'last_updated',
]
def _get_termination(self, obj, side):
"""
Serialize a nested representation of a termination.
"""
if side.lower() not in ['a', 'b']:
raise ValueError("Termination side must be either A or B.")
termination = getattr(obj, 'termination_{}'.format(side.lower()))
if termination is None:
return None
serializer = get_serializer_for_model(termination, prefix='Nested')
def _get_terminations_type(self, obj, side):
assert side in CableEndChoices.values()
terms = getattr(obj, f'get_{side.lower()}_terminations')()
if terms:
ct = ContentType.objects.get_for_model(terms[0])
return f"{ct.app_label}.{ct.model}"
def _get_terminations(self, obj, side):
assert side in CableEndChoices.values()
terms = getattr(obj, f'get_{side.lower()}_terminations')()
if not terms:
return []
termination_type = ContentType.objects.get_for_model(terms[0])
serializer = get_serializer_for_model(termination_type.model_class(), prefix='Nested')
context = {'request': self.context['request']}
data = serializer(termination, context=context).data
data = serializer(terms, context=context, many=True).data
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_a(self, obj):
return self._get_termination(obj, 'a')
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_a_terminations_type(self, obj):
return self._get_terminations_type(obj, CableEndChoices.SIDE_A)
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_b_terminations_type(self, obj):
return self._get_terminations_type(obj, CableEndChoices.SIDE_B)
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_b(self, obj):
return self._get_termination(obj, 'b')
def get_a_terminations(self, obj):
return self._get_terminations(obj, CableEndChoices.SIDE_A)
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_b_terminations(self, obj):
return self._get_terminations(obj, CableEndChoices.SIDE_B)
class TracedCableSerializer(serializers.ModelSerializer):
@ -1047,46 +1060,40 @@ class TracedCableSerializer(serializers.ModelSerializer):
]
class CableTerminationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data
class CablePathSerializer(serializers.ModelSerializer):
origin_type = ContentTypeField(read_only=True)
origin = serializers.SerializerMethodField(read_only=True)
destination_type = ContentTypeField(read_only=True)
destination = serializers.SerializerMethodField(read_only=True)
path = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CablePath
fields = [
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_origin(self, obj):
"""
Return the appropriate serializer for the origin.
"""
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.origin, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_destination(self, obj):
"""
Return the appropriate serializer for the destination, if any.
"""
if obj.destination_id is not None:
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.destination, context=context).data
return None
fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_path(self, obj):
ret = []
for node in obj.get_path():
serializer = get_serializer_for_model(node, prefix='Nested')
for nodes in obj.path_objects:
serializer = get_serializer_for_model(nodes[0], prefix='Nested')
context = {'request': self.context['request']}
ret.append(serializer(node, context=context).data)
ret.append(serializer(nodes, context=context, many=True).data)
return ret
@ -1129,7 +1136,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
]
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@ -1153,13 +1160,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
# Cables
router.register('cables', views.CableViewSet)
router.register('cable-terminations', views.CableTerminationViewSet)
# Virtual chassis
router.register('virtual-chassis', views.VirtualChassisViewSet)

View File

@ -13,7 +13,9 @@ from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.views import ConfigContextQuerySetMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@ -51,37 +53,30 @@ class PathEndpointMixin(object):
# Initialize the path array
path = []
# Render SVG image if requested
if request.GET.get('render', None) == 'svg':
# Render SVG
try:
width = min(int(request.GET.get('width')), 1600)
width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
except (ValueError, TypeError):
width = None
drawing = obj.get_trace_svg(
base_url=request.build_absolute_uri('/'),
width=width
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
width = CABLE_TRACE_SVG_DEFAULT_WIDTH
drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width)
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
# Serialize path objects, iterating over each three-tuple in the path
for near_end, cable, far_end in obj.trace():
if near_end is None:
# Split paths
if near_end is not None:
serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
near_end = serializer_a(near_end, many=True, context={'request': request}).data
else:
# Path is split; stop here
break
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
x = serializer_a(near_end, context={'request': request}).data
if cable is not None:
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
else:
y = None
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
if far_end is not None:
serializer_b = get_serializer_for_model(far_end, prefix='Nested')
z = serializer_b(far_end, context={'request': request}).data
else:
z = None
serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
far_end = serializer_b(far_end, many=True, context={'request': request}).data
path.append((x, y, z))
path.append((near_end, cable, far_end))
return Response(path)
@ -94,7 +89,7 @@ class PassThroughPortMixin(object):
Return all CablePaths which traverse a given pass-through port.
"""
obj = get_object_or_404(self.queryset, pk=pk)
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
cablepaths = CablePath.objects.filter(_nodes__contains=obj)
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
return Response(serializer.data)
@ -557,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet):
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsolePort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet
@ -566,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet
@ -575,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet
@ -584,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerOutlet.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet
@ -593,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@ -603,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
)
serializer_class = serializers.FrontPortSerializer
filterset_class = filtersets.FrontPortFilterSet
@ -612,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = RearPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
)
serializer_class = serializers.RearPortSerializer
filterset_class = filtersets.RearPortFilterSet
@ -657,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
#
class CableViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
queryset = Cable.objects.prefetch_related('terminations__termination')
serializer_class = serializers.CableSerializer
filterset_class = filtersets.CableFilterSet
class CableTerminationViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CableTermination.objects.prefetch_related('cable', 'termination')
serializer_class = serializers.CableTerminationSerializer
filterset_class = filtersets.CableTerminationFilterSet
#
# Virtual chassis
#
@ -698,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet
@ -758,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet):
device=peer_device,
name=peer_interface_name
)
endpoint = peer_interface.connected_endpoint
endpoints = peer_interface.connected_endpoints
# If an Interface, return the parent device
if type(endpoint) is Interface:
if endpoints and type(endpoints[0]) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
pk=endpoints[0].device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)

View File

@ -1282,6 +1282,22 @@ class CableLengthUnitChoices(ChoiceSet):
)
#
# CableTerminations
#
class CableEndChoices(ChoiceSet):
SIDE_A = 'A'
SIDE_B = 'B'
CHOICES = (
(SIDE_A, 'A'),
(SIDE_B, 'B'),
# ('', ''),
)
#
# PowerFeeds
#

View File

@ -85,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q(
# Cabling and connections
#
CABLE_TRACE_SVG_DEFAULT_WIDTH = 400
# Cable endpoint types
CABLE_TERMINATION_MODELS = Q(
Q(app_label='circuits', model__in=(

View File

@ -21,6 +21,7 @@ from .models import *
__all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
@ -1117,7 +1118,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
)
class CableTerminationFilterSet(django_filters.FilterSet):
class CabledObjectFilterSet(django_filters.FilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@ -1140,7 +1141,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
class ConsolePortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@ -1150,13 +1151,13 @@ class ConsolePortFilterSet(
class Meta:
model = ConsolePort
fields = ['id', 'name', 'label', 'description']
fields = ['id', 'name', 'label', 'description', 'cable_end']
class ConsoleServerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@ -1166,13 +1167,13 @@ class ConsoleServerPortFilterSet(
class Meta:
model = ConsoleServerPort
fields = ['id', 'name', 'label', 'description']
fields = ['id', 'name', 'label', 'description', 'cable_end']
class PowerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@ -1182,13 +1183,13 @@ class PowerPortFilterSet(
class Meta:
model = PowerPort
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
class PowerOutletFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@ -1202,13 +1203,13 @@ class PowerOutletFilterSet(
class Meta:
model = PowerOutlet
fields = ['id', 'name', 'label', 'feed_leg', 'description']
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
@ -1288,7 +1289,7 @@ class InterfaceFilterSet(
model = Interface
fields = [
'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',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
]
def filter_device(self, queryset, name, value):
@ -1342,7 +1343,7 @@ class InterfaceFilterSet(
class FrontPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet
CabledObjectFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@ -1351,13 +1352,13 @@ class FrontPortFilterSet(
class Meta:
model = FrontPort
fields = ['id', 'name', 'label', 'type', 'color', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
class RearPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet
CabledObjectFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@ -1366,7 +1367,7 @@ class RearPortFilterSet(
class Meta:
model = RearPort
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@ -1514,10 +1515,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
termination_a_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_a_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
termination_b_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_b_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@ -1528,44 +1537,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
choices=ColorChoices
)
device_id = MultiValueNumberFilter(
method='filter_device'
method='filter_by_termination'
)
device = MultiValueCharFilter(
method='filter_device',
method='filter_by_termination',
field_name='device__name'
)
rack_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__rack_id'
method='filter_by_termination',
field_name='rack_id'
)
rack = MultiValueCharFilter(
method='filter_device',
field_name='device__rack__name'
method='filter_by_termination',
field_name='rack__name'
)
location_id = MultiValueNumberFilter(
method='filter_by_termination',
field_name='location_id'
)
location = MultiValueCharFilter(
method='filter_by_termination',
field_name='location__name'
)
site_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__site_id'
method='filter_by_termination',
field_name='site_id'
)
site = MultiValueCharFilter(
method='filter_device',
field_name='device__site__slug'
method='filter_by_termination',
field_name='site__slug'
)
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
fields = ['id', 'label', 'length', 'length_unit']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
def filter_device(self, queryset, name, value):
queryset = queryset.filter(
Q(**{'_termination_a_{}__in'.format(name): value}) |
Q(**{'_termination_b_{}__in'.format(name): value})
)
return queryset
def filter_by_termination(self, queryset, name, value):
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
# Supported objects: device, rack, location, site
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
class CableTerminationFilterSet(BaseFilterSet):
class Meta:
model = CableTermination
fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@ -1625,7 +1647,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',
@ -1679,7 +1701,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn
class Meta:
model = PowerFeed
fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
fields = [
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
]
def search(self, queryset, name, value):
if not value.strip():

View File

@ -1,279 +1,171 @@
from django import forms
from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
'ConnectCableToConsolePortForm',
'ConnectCableToConsoleServerPortForm',
'ConnectCableToFrontPortForm',
'ConnectCableToInterfaceForm',
'ConnectCableToPowerFeedForm',
'ConnectCableToPowerPortForm',
'ConnectCableToPowerOutletForm',
'ConnectCableToRearPortForm',
)
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import CableForm
class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
"""
Base form for connecting a Cable to a Device component
"""
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
'rack_id': '$termination_b_rack',
}
)
def get_cable_form(a_type, b_type):
class Meta:
model = Cable
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
'type': StaticSelect,
'length_unit': StaticSelect,
}
class FormMetaclass(forms.models.ModelFormMetaclass):
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
def __new__(mcs, name, bases, attrs):
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': f'$termination_{cable_end}_region',
'group_id': f'$termination_{cable_end}_sitegroup',
}
)
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': f'$termination_{cable_end}_site'
}
)
# Device component
if hasattr(term_cls, 'device'):
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
initial_params={
'devices': f'$termination_{cable_end}_device'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
initial_params={
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
'rack_id': f'$termination_{cable_end}_rack',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label=term_cls._meta.verbose_name.title(),
disabled_indicator='_occupied',
query_params={
'device_id': f'$termination_{cable_end}_device',
}
)
# PowerFeed
elif term_cls == PowerFeed:
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
initial_params={
'powerfeeds__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Power Feed',
disabled_indicator='_occupied',
query_params={
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
}
)
# CircuitTermination
elif term_cls == CircuitTermination:
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
initial_params={
'circuits': f'$termination_{cable_end}_circuit'
},
required=False
)
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
initial_params={
'terminations__in': f'${cable_end}_terminations'
},
query_params={
'provider_id': f'$termination_{cable_end}_provider',
'site_id': f'$termination_{cable_end}_site',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': f'termination_{cable_end}_circuit',
}
)
return super().__new__(mcs, name, bases, attrs)
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device',
'kind': 'physical',
}
)
class _CableForm(CableForm, metaclass=FormMetaclass):
def __init__(self, *args, **kwargs):
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'):
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
super().__init__(*args, **kwargs)
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance
self.initial['a_terminations'] = self.instance.get_a_terminations()
self.initial['b_terminations'] = self.instance.get_b_terminations()
def save(self, *args, **kwargs):
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
required=False
)
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
query_params={
'provider_id': '$termination_b_provider',
'site_id': '$termination_b_site',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=CircuitTermination.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': '$termination_b_circuit'
}
)
# Set the A/B terminations on the Cable instance
self.instance.a_terminations = self.cleaned_data['a_terminations']
self.instance.b_terminations = self.cleaned_data['b_terminations']
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
return super().save(*args, **kwargs)
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_powerpanel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=PowerFeed.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'power_panel_id': '$termination_b_powerpanel'
}
)
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
return _CableForm

View File

@ -730,7 +730,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable
fieldsets = (
(None, ('q', 'tag')),
('Location', ('site_id', 'rack_id', 'device_id')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
@ -747,13 +747,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location'),
null_option='None',
query_params={
'site_id': '$site_id'
}
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id'
'site_id': '$site_id',
'location_id': '$location_id',
}
)
device_id = DynamicModelMultipleChoiceField(
@ -761,8 +771,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'location_id': '$location_id',
'rack_id': '$rack_id',
'tenant_id': '$tenant_id',
},
label=_('Device')
)

View File

@ -0,0 +1,5 @@
class CabledObjectMixin:
def resolve_cable_end(self, info):
# Handle empty values
return self.cable_end or None

View File

@ -7,6 +7,7 @@ from extras.graphql.mixins import (
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin
__all__ = (
'CableType',
@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
return self.length_unit or None
class ConsolePortType(ComponentObjectType):
class CableTerminationType(NetBoxObjectType):
class Meta:
model = models.CableTermination
fields = '__all__'
filterset_class = filtersets.CableTerminationFilterSet
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.ConsolePort
@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None
class ConsoleServerPortType(ComponentObjectType):
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.ConsoleServerPort
@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
return self.airflow or None
class FrontPortType(ComponentObjectType):
class FrontPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.FrontPort
@ -219,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType):
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.Interface
@ -322,7 +331,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType):
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
class Meta:
model = models.PowerFeed
@ -330,7 +339,7 @@ class PowerFeedType(NetBoxObjectType):
filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType):
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.PowerOutlet
@ -366,7 +375,7 @@ class PowerPanelType(NetBoxObjectType):
filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType):
class PowerPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.PowerPort
@ -418,7 +427,7 @@ class RackRoleType(OrganizationalObjectType):
filterset_class = filtersets.RackRoleFilterSet
class RearPortType(ComponentObjectType):
class RearPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.RearPort

View File

@ -81,7 +81,7 @@ class Command(BaseCommand):
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath(obj)
create_cablepath([obj])
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)

View File

@ -0,0 +1,95 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0156_location_status'),
]
operations = [
# Create CableTermination model
migrations.CreateModel(
name='CableTermination',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('cable_end', models.CharField(max_length=1)),
('termination_id', models.PositiveBigIntegerField()),
('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
],
options={
'ordering': ('cable', 'cable_end', 'pk'),
},
),
migrations.AddConstraint(
model_name='cabletermination',
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
),
# Update CablePath model
migrations.RenameField(
model_name='cablepath',
old_name='path',
new_name='_nodes',
),
migrations.AddField(
model_name='cablepath',
name='path',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='cablepath',
name='is_complete',
field=models.BooleanField(default=False),
),
# Add cable_end field to cable termination models
migrations.AddField(
model_name='consoleport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='frontport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='interface',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerfeed',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='rearport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@ -0,0 +1,76 @@
from django.db import migrations
def cache_related_objects(termination):
"""
Replicate caching logic from CableTermination.cache_related_objects()
"""
attrs = {}
# Device components
if getattr(termination, 'device', None):
attrs['_device'] = termination.device
attrs['_rack'] = termination.device.rack
attrs['_location'] = termination.device.location
attrs['_site'] = termination.device.site
# Power feeds
elif getattr(termination, 'rack', None):
attrs['_rack'] = termination.rack
attrs['_location'] = termination.rack.location
attrs['_site'] = termination.rack.site
# Circuit terminations
elif getattr(termination, 'site', None):
attrs['_site'] = termination.site
return attrs
def populate_cable_terminations(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Cable = apps.get_model('dcim', 'Cable')
CableTermination = apps.get_model('dcim', 'CableTermination')
# Retrieve the necessary data from Cable objects
cables = Cable.objects.values(
'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id'
)
# Queue CableTerminations to be created
cable_terminations = []
for i, cable in enumerate(cables, start=1):
for cable_end in ('a', 'b'):
# We must manually instantiate the termination object, because GFK fields are not
# supported within migrations.
termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type'])
termination_model = apps.get_model(termination_ct.app_label, termination_ct.model)
termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id'])
cable_terminations.append(CableTermination(
cable_id=cable['id'],
cable_end=cable_end.upper(),
termination_type_id=cable[f'termination_{cable_end}_type'],
termination_id=cable[f'termination_{cable_end}_id'],
**cache_related_objects(termination)
))
# Bulk create the termination objects
CableTermination.objects.bulk_create(cable_terminations, batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0157_new_cabling_models'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,50 @@
from django.db import migrations
from dcim.utils import compile_path_node
def populate_cable_paths(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
CablePath = apps.get_model('dcim', 'CablePath')
# Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
cable_paths = []
for cablepath in CablePath.objects.all():
# Origin
origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
cablepath.path.append([origin])
cablepath._nodes.insert(0, origin)
# Transit nodes
cablepath.path.extend([
[node] for node in cablepath._nodes[1:]
])
# Destination
if cablepath.destination_id:
destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
cablepath.path.append([destination])
cablepath._nodes.append(destination)
cablepath.is_complete = True
cable_paths.append(cablepath)
# Bulk update all CableTerminations
CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0158_populate_cable_terminations'),
]
operations = [
migrations.RunPython(
code=populate_cable_paths,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,42 @@
from django.db import migrations
def populate_cable_terminations(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
ContentType = apps.get_model('contenttypes', 'ContentType')
cable_termination_models = (
apps.get_model('dcim', 'ConsolePort'),
apps.get_model('dcim', 'ConsoleServerPort'),
apps.get_model('dcim', 'PowerPort'),
apps.get_model('dcim', 'PowerOutlet'),
apps.get_model('dcim', 'Interface'),
apps.get_model('dcim', 'FrontPort'),
apps.get_model('dcim', 'RearPort'),
apps.get_model('dcim', 'PowerFeed'),
apps.get_model('circuits', 'CircuitTermination'),
)
for model in cable_termination_models:
ct = ContentType.objects.get_for_model(model)
model.objects.filter(
id__in=Cable.objects.filter(termination_a_type=ct).values_list('termination_a_id', flat=True)
).update(cable_end='A')
model.objects.filter(
id__in=Cable.objects.filter(termination_b_type=ct).values_list('termination_b_id', flat=True)
).update(cable_end='B')
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0159_populate_cable_paths'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,134 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0160_populate_cable_ends'),
]
operations = [
# Remove old fields from Cable
migrations.AlterModelOptions(
name='cable',
options={'ordering': ('pk',)},
),
migrations.AlterUniqueTogether(
name='cable',
unique_together=set(),
),
migrations.RemoveField(
model_name='cable',
name='termination_a_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_a_type',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_type',
),
migrations.RemoveField(
model_name='cable',
name='_termination_a_device',
),
migrations.RemoveField(
model_name='cable',
name='_termination_b_device',
),
# Remove old fields from CablePath
migrations.AlterUniqueTogether(
name='cablepath',
unique_together=set(),
),
migrations.RemoveField(
model_name='cablepath',
name='destination_id',
),
migrations.RemoveField(
model_name='cablepath',
name='destination_type',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_id',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_type',
),
# Remove link peer type/ID fields from cable termination models
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_type',
),
]

View File

@ -1,10 +1,12 @@
import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse
from dcim.choices import *
@ -13,17 +15,21 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from netbox.models import NetBoxModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .devices import Device
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort
__all__ = (
'Cable',
'CablePath',
'CableTermination',
)
trace_paths = Signal()
#
# Cables
#
@ -32,28 +38,6 @@ class Cable(NetBoxModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveBigIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveBigIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.CharField(
max_length=50,
choices=CableTypeChoices,
@ -96,31 +80,11 @@ class Cable(NetBoxModel):
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
ordering = ('pk',)
def __init__(self, *args, **kwargs):
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
@ -129,19 +93,12 @@ class Cable(NetBoxModel):
# Cache the original status so we can check later if it's been changed
self._orig_status = self.status
@classmethod
def from_db(cls, db, field_names, values):
"""
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
"""
instance = super().from_db(db, field_names, values)
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
# Assign any *new* CableTerminations for the instance. These will replace any existing
# terminations on save().
if a_terminations is not None:
self.a_terminations = a_terminations
if b_terminations is not None:
self.b_terminations = b_terminations
def __str__(self):
pk = self.pk or self._pk
@ -151,123 +108,41 @@ class Cable(NetBoxModel):
return reverse('dcim:cable', args=[self.pk])
def clean(self):
from circuits.models import CircuitTermination
super().clean()
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# If editing an existing Cable instance, check that neither termination has been modified.
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
'termination_b': err_msg
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# Check that two connected RearPorts have the same number of positions (if both are >1)
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
f"{self.termination_b} has {self.termination_b.positions}. "
f"Both terminations must have the same number of positions (if greater than one)."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
raise ValidationError({
'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
})
if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
raise ValidationError({
'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
})
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
a_terminations = [
CableTermination(cable=self, cable_end='A', termination=t)
for t in getattr(self, 'a_terminations', [])
]
b_terminations = [
CableTermination(cable=self, cable_end='B', termination=t)
for t in getattr(self, 'b_terminations', [])
]
# Check that all termination objects for either end are of the same type
for terms in (a_terminations, b_terminations):
if len(terms) > 1 and not all(t.termination_type == terms[0].termination_type for t in terms[1:]):
raise ValidationError("Cannot connect different termination types to same end of cable.")
# Check that termination types are compatible
if a_terminations and b_terminations:
a_type = a_terminations[0].termination_type.model
b_type = b_terminations[0].termination_type.model
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
# Run clean() on any new CableTerminations
for cabletermination in [*a_terminations, *b_terminations]:
cabletermination.clean()
def save(self, *args, **kwargs):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
@ -275,199 +150,454 @@ class Cable(NetBoxModel):
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
# Retrieve existing A/B terminations for the Cable
a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
# Delete stale CableTerminations
if hasattr(self, 'a_terminations'):
for termination, ct in a_terminations.items():
if termination not in self.a_terminations:
ct.delete()
if hasattr(self, 'b_terminations'):
for termination, ct in b_terminations.items():
if termination not in self.b_terminations:
ct.delete()
# Save new CableTerminations (if any)
if hasattr(self, 'a_terminations'):
for termination in self.a_terminations:
if termination not in a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).save()
if hasattr(self, 'b_terminations'):
for termination in self.b_terminations:
if termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
trace_paths.send(Cable, instance=self, created=_created)
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
def get_compatible_types(self):
def get_a_terminations(self):
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
]
def get_b_terminations(self):
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
]
class CableTermination(models.Model):
"""
A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
"""
cable = models.ForeignKey(
to='dcim.Cable',
on_delete=models.CASCADE,
related_name='terminations'
)
cable_end = models.CharField(
max_length=1,
choices=CableEndChoices,
verbose_name='End'
)
termination_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_id = models.PositiveBigIntegerField()
termination = GenericForeignKey(
ct_field='termination_type',
fk_field='termination_id'
)
# Cached associations to enable efficient filtering
_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
blank=True,
null=True
)
_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.CASCADE,
blank=True,
null=True
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
blank=True,
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('cable', 'cable_end', 'pk')
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),
name='dcim_cable_termination_unique_termination'
),
)
def __str__(self):
return f'Cable {self.cable} to {self.termination}'
def clean(self):
super().clean()
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
})
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError({
'termination': "Circuit terminations attached to a provider network may not be cabled."
})
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Set the cable on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=self.cable,
cable_end=self.cable_end
)
def delete(self, *args, **kwargs):
# Delete the cable association on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=None,
cable_end=''
)
super().delete(*args, **kwargs)
def cache_related_objects(self):
"""
Return all termination types compatible with termination A.
Cache objects related to the termination (e.g. device, rack, site) directly on the object to
enable efficient filtering.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
assert self.termination is not None
# Device components
if getattr(self.termination, 'device', None):
self._device = self.termination.device
self._rack = self.termination.device.rack
self._location = self.termination.device.location
self._site = self.termination.device.site
# Power feeds
elif getattr(self.termination, 'rack', None):
self._rack = self.termination.rack
self._location = self.termination.rack.location
self._site = self.termination.rack.site
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
class CablePath(models.Model):
"""
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
not terminate on a PathEndpoint).
A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
including all intermediate elements.
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
`path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
terminate to one or more objects.) For example, consider the following
topology:
1 2 3
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
A B C
Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
Front Port 2 Front Port 4
This path would be expressed as:
CablePath(
origin = Interface A
destination = Interface B
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
path = [
[Interface 1],
[Cable A],
[Front Port 1, Front Port 2],
[Rear Port 1],
[Cable B],
[Rear Port 2],
[Front Port 3, Front Port 4],
[Cable C],
[Interface 2],
]
)
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
"connected".
`is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
path diverges across multiple cables.
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
"""
origin_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
path = models.JSONField(
default=list
)
origin_id = models.PositiveBigIntegerField()
origin = GenericForeignKey(
ct_field='origin_type',
fk_field='origin_id'
)
destination_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
destination_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
destination = GenericForeignKey(
ct_field='destination_type',
fk_field='destination_id'
)
path = PathField()
is_active = models.BooleanField(
default=False
)
is_complete = models.BooleanField(
default=False
)
is_split = models.BooleanField(
default=False
)
class Meta:
unique_together = ('origin_type', 'origin_id')
_nodes = PathField()
def __str__(self):
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
return f"Path #{self.pk}: {len(self.path)} hops"
def save(self, *args, **kwargs):
# Save the flattened nodes list
self._nodes = list(itertools.chain(*self.path))
super().save(*args, **kwargs)
# Record a direct reference to this CablePath on its originating object
model = self.origin._meta.model
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
# Record a direct reference to this CablePath on its originating object(s)
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
@property
def origin_type(self):
if self.path:
ct_id, _ = decompile_path_node(self.path[0][0])
return ContentType.objects.get_for_id(ct_id)
@property
def destination_type(self):
if self.is_complete:
ct_id, _ = decompile_path_node(self.path[-1][0])
return ContentType.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
@property
def origins(self):
"""
Return the list of originating objects.
"""
if hasattr(self, '_path_objects'):
return self.path_objects[0]
return [
path_node_to_object(node) for node in self.path[0]
]
@property
def destinations(self):
"""
Return the list of destination objects, if the path is complete.
"""
if not self.is_complete:
return []
if hasattr(self, '_path_objects'):
return self.path_objects[-1]
return [
path_node_to_object(node) for node in self.path[-1]
]
@property
def segment_count(self):
total_length = 1 + len(self.path) + (1 if self.destination else 0)
return int(total_length / 3)
return int(len(self.path) / 3)
@classmethod
def from_origin(cls, origin):
def from_origin(cls, terminations):
"""
Create a new CablePath instance as traced from the given path origin.
Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
of the same type and must belong to the same parent object.
"""
from circuits.models import CircuitTermination
if origin is None or origin.link is None:
return None
destination = None
path = []
position_stack = []
is_complete = False
is_active = True
is_split = False
node = origin
while node.link is not None:
if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
while terminations:
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# Step 1: Record the near-end termination object(s)
path.append([
object_to_path_node(t) for t in terminations
])
# Step 2: Determine the attached link (Cable or WirelessLink), if any
link = terminations[0].link
assert all(t.link == link for t in terminations[1:])
if link is None and len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
elif link is None:
# Otherwise, halt the trace if no link exists
break
assert type(link) in (Cable, WirelessLink)
# Step 3: Record the link and update path status if not "connected"
path.append([object_to_path_node(link)])
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False
# Follow the link to its far-end termination
path.append(object_to_path_node(node.link))
peer_termination = node.get_link_peer()
# Step 4: Determine the far-end terminations
if isinstance(link, Cable):
termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,
termination_id__in=[t.pk for t in terminations]
)
# Terminations must all belong to same end of Cable
local_cable_end = local_cable_terminations[0].cable_end
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
remote_cable_terminations = CableTermination.objects.filter(
cable=link,
cable_end='A' if local_cable_end == 'B' else 'B'
)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:
# WirelessLink
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Step 5: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations
])
# Follow a RearPort to its corresponding FrontPort (if any)
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
# Step 6: Determine the "next hop" terminations, if applicable
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
if len(rear_ports) > 1:
assert all(rp.positions == 1 for rp in rear_ports)
elif rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
# Determine the peer FrontPort's position
if peer_termination.positions == 1:
position = 1
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
elif position_stack:
position = position_stack.pop()
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop()
)
else:
# No position indicated: path has split, so we stop at the RearPort
# No position indicated: path has split, so we stop at the RearPorts
is_split = True
break
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
terminations = front_ports
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
elif circuit_termination.provider_network:
# Circuit terminates to a ProviderNetwork
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.site)],
])
break
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
elif isinstance(peer_termination, CircuitTermination):
path.append(object_to_path_node(peer_termination))
# Get peer CircuitTermination
node = peer_termination.get_peer_termination()
if node:
path.append(object_to_path_node(node))
if node.provider_network:
destination = node.provider_network
break
elif node.site and not node.cable:
destination = node.site
break
else:
# No peer CircuitTermination exists; halt the trace
break
terminations = [circuit_termination]
# Anything else marks the end of the path
else:
destination = peer_termination
is_complete = True
break
if destination is None:
is_active = False
return cls(
origin=origin,
destination=destination,
path=path,
is_complete=is_complete,
is_active=is_active,
is_split=is_split
)
def get_path(self):
def retrace(self):
"""
Retrace the path from the currently-defined originating termination(s)
"""
_new = self.from_origin(self.origins)
if _new:
self.path = _new.path
self.is_complete = _new.is_complete
self.is_active = _new.is_active
self.is_split = _new.is_split
self.save()
else:
self.delete()
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.path:
for node in self._nodes:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
@ -484,19 +614,15 @@ class CablePath(models.Model):
# Replicate the path using the prefetched objects.
path = []
for node in self.path:
ct_id, object_id = decompile_path_node(node)
path.append(prefetched[ct_id][object_id])
for step in self.path:
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
nodes.append(prefetched[ct_id][object_id])
path.append(nodes)
return path
@property
def last_node(self):
"""
Return either the destination or the last node within the path.
"""
return self.destination or path_node_to_object(self.path[-1])
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
@ -504,7 +630,7 @@ class CablePath(models.Model):
cable_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = []
for node in self.path:
for node in self._nodes:
ct, id = decompile_path_node(node)
if ct == cable_ct:
cable_ids.append(id)
@ -527,6 +653,6 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
rearport = path_node_to_object(self.path[-1])
rearport = path_node_to_object(self._nodes[-1])
return FrontPort.objects.filter(rear_port=rearport)

View File

@ -1,6 +1,8 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
__all__ = (
'BaseInterface',
'LinkTermination',
'CabledObjectModel',
'ConsolePort',
'ConsoleServerPort',
'DeviceBay',
@ -103,14 +104,10 @@ class ModularComponentModel(ComponentModel):
abstract = True
class LinkTermination(models.Model):
class CabledObjectModel(models.Model):
"""
An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
reference the attached Cable or WirelessLink instance, respectively.
`_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
shortcut to referencing `instance.link.termination_b`, for example.
An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
"""
cable = models.ForeignKey(
to='dcim.Cable',
@ -119,36 +116,21 @@ class LinkTermination(models.Model):
blank=True,
null=True
)
_link_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
cable_end = models.CharField(
max_length=1,
blank=True,
null=True
)
_link_peer_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
_link_peer = GenericForeignKey(
ct_field='_link_peer_type',
fk_field='_link_peer_id'
choices=CableEndChoices
)
mark_connected = models.BooleanField(
default=False,
help_text="Treat as if a cable is connected"
)
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
_cabled_as_a = GenericRelation(
to='dcim.Cable',
content_type_field='termination_a_type',
object_id_field='termination_a_id'
)
_cabled_as_b = GenericRelation(
to='dcim.Cable',
content_type_field='termination_b_type',
object_id_field='termination_b_id'
cable_terminations = GenericRelation(
to='dcim.CableTermination',
content_type_field='termination_type',
object_id_field='termination_id',
related_query_name='%(class)s',
)
class Meta:
@ -157,22 +139,19 @@ class LinkTermination(models.Model):
def clean(self):
super().clean()
if self.mark_connected and self.cable_id:
if self.cable and not self.cable_end:
raise ValidationError({
"cable_end": "Must specify cable end (A or B) when attaching a cable."
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": "Cable end must not be set without a cable."
})
if self.mark_connected and self.cable:
raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached."
})
def get_link_peer(self):
return self._link_peer
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
@property
def link(self):
"""
@ -180,10 +159,31 @@ class LinkTermination(models.Model):
"""
return self.cable
@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 []
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
@property
def opposite_cable_end(self):
if not self.cable_end:
return None
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
class PathEndpoint(models.Model):
"""
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
@ -206,50 +206,41 @@ class PathEndpoint(models.Model):
origin = self
path = []
# Construct the complete path
# Construct the complete path (including e.g. bridged interfaces)
while origin is not None:
if origin._path is None:
break
path.extend([origin, *origin._path.get_path()])
while (len(path) + 1) % 3:
path.extend(origin._path.path_objects)
while (len(path)) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None)
path.append(origin._path.destination)
# by inserting empty entries immediately prior to the path's destination node(s)
path.append([])
# Check for bridge interface to continue the trace
origin = getattr(origin._path.destination, 'bridge', None)
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations
if len(destinations) == 1:
origin = getattr(destinations[0], 'bridge', None)
else:
origin = None
# Return the path as a list of three-tuples (A termination, cable, B termination)
# Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
return list(zip(*[iter(path)] * 3))
def get_trace_svg(self, base_url=None, width=None):
if width is not None:
trace = CableTraceSVG(self, base_url=base_url, width=width)
else:
trace = CableTraceSVG(self, base_url=base_url)
return trace.render()
@property
def path(self):
return self._path
@property
def connected_endpoint(self):
@cached_property
def connected_endpoints(self):
"""
Caching accessor for the attached CablePath's destination (if any)
"""
if not hasattr(self, '_connected_endpoint'):
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
return self._path.destinations if self._path else []
#
# Console components
#
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@ -276,7 +267,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@ -307,7 +298,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
# Power components
#
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@ -348,36 +339,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
def get_downstream_powerports(self, leg=None):
"""
Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
below, PP1.get_downstream_powerports() would return PP2-4.
---- PO1 <---> PP2
/
PP1 ------- PO2 <---> PP3
\
---- PO3 <---> PP4
"""
poweroutlets = self.poweroutlets.filter(cable__isnull=False)
if leg:
poweroutlets = poweroutlets.filter(feed_leg=leg)
if not poweroutlets:
return PowerPort.objects.none()
q = Q()
for poweroutlet in poweroutlets:
q |= Q(
cable=poweroutlet.cable,
cable_end=poweroutlet.opposite_cable_end
)
return PowerPort.objects.filter(q)
def get_power_draw(self):
"""
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
"""
from dcim.models import PowerFeed
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually
if self.allocated_draw is None and self.maximum_draw is None:
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
utilization = self.get_downstream_powerports().aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
ret = {
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': len(outlet_ids),
'outlet_count': self.poweroutlets.count(),
'legs': [],
}
# Calculate per-leg aggregates for three-phase feeds
if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
# Calculate per-leg aggregates for three-phase power feeds
if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
utilization = self.get_downstream_powerports(leg=leg).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
@ -385,7 +397,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
'name': leg_name,
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': len(outlet_ids),
'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
})
return ret
@ -394,12 +406,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
return {
'allocated': self.allocated_draw or 0,
'maximum': self.maximum_draw or 0,
'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
'outlet_count': self.poweroutlets.count(),
'legs': [],
}
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@ -437,9 +449,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
raise ValidationError(
"Parent power port ({}) must belong to the same device".format(self.power_port)
)
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
#
@ -513,7 +523,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@ -829,6 +839,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
def link(self):
return self.cable or self.wireless_link
@cached_property
def link_peers(self):
if self.cable:
return super().link_peers
if self.wireless_link:
# Return the opposite side of the attached wireless link
if self.wireless_link.interface_a == self:
return [self.wireless_link.interface_b]
else:
return [self.wireless_link.interface_a]
return []
@property
def l2vpn_termination(self):
return self.l2vpn_terminations.first()
@ -838,7 +860,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
# Pass-through ports
#
class FrontPort(ModularComponentModel, LinkTermination):
class FrontPort(ModularComponentModel, CabledObjectModel):
"""
A pass-through port on the front of a Device.
"""
@ -891,7 +913,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
})
class RearPort(ModularComponentModel, LinkTermination):
class RearPort(ModularComponentModel, CabledObjectModel):
"""
A pass-through port on the rear of a Device.
"""

View File

@ -9,7 +9,7 @@ from dcim.constants import *
from netbox.config import ConfigItem
from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint
from .device_components import CabledObjectModel, PathEndpoint
__all__ = (
'PowerFeed',
@ -67,7 +67,7 @@ class PowerPanel(NetBoxModel):
)
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
"""
An electrical circuit delivered from a PowerPanel.
"""

View File

@ -432,17 +432,17 @@ class Rack(NetBoxModel):
if not available_power_total:
return 0
pf_powerports = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerFeed),
_link_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
powerports = []
for powerfeed in powerfeeds:
powerports.extend([
peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
])
return int(allocated_draw_total / available_power_total * 100)
allocated_draw = sum([
powerport.get_power_draw()['allocated'] for powerport in powerports
])
return int(allocated_draw / available_power_total * 100)
class RackReservation(NetBoxModel):

View File

@ -1,11 +1,11 @@
import logging
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@ -68,73 +68,55 @@ def clear_virtualchassis_members(instance, **kwargs):
# Cables
#
@receiver(post_save, sender=Cable)
@receiver(trace_paths, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs):
"""
When a Cable is saved, check for and update its two connected endpoints
When a Cable is saved with new terminations, retrace any affected cable paths.
"""
logger = logging.getLogger('netbox.dcim.cable')
if raw:
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
return
# Cache the Cable on its two termination points
if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance
instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save()
if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save()
# Create/update cable paths
if created:
for termination in (instance.termination_a, instance.termination_b):
if isinstance(termination, PathEndpoint):
create_cablepath(termination)
# Update cable paths if new terminations have been set
if hasattr(instance, 'a_terminations') or hasattr(instance, 'b_terminations'):
a_terminations = []
b_terminations = []
for t in instance.terminations.all():
if t.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(t.termination)
else:
rebuild_paths(termination)
b_terminations.append(t.termination)
for nodes in [a_terminations, b_terminations]:
# Examine type of first termination to determine object type (all must be the same)
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepath(nodes)
else:
rebuild_paths(nodes)
# Update status of CablePaths if Cable status has been changed
elif instance.status != instance._orig_status:
# We currently don't support modifying either termination of an existing Cable. (This
# may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(path__contains=instance).update(is_active=False)
CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
else:
rebuild_paths(instance)
rebuild_paths([instance])
@receiver(post_delete, sender=Cable)
def retrace_cable_paths(instance, **kwargs):
"""
When a Cable is deleted, check for and update its connected endpoints
"""
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
cablepath.retrace()
@receiver(post_delete, sender=CableTermination)
def nullify_connected_endpoints(instance, **kwargs):
"""
When a Cable is deleted, check for and update its two connected endpoints
Disassociate the Cable from the termination object.
"""
logger = logging.getLogger('netbox.dcim.cable')
# Disassociate the Cable from its termination points
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model
model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model
model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
cp = CablePath.from_origin(cablepath.origin)
if cp:
CablePath.objects.filter(pk=cablepath.pk).update(
path=cp.path,
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=cp.destination.pk if cp.destination else None,
is_active=cp.is_active,
is_split=cp.is_split
)
else:
cablepath.delete()
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')

View File

@ -1,12 +1,14 @@
import svgwrite
from django.conf import settings
from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Rect
from svgwrite.shapes import Line, Polyline, Rect
from svgwrite.text import Text
from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color
__all__ = (
'CableTraceSVG',
)
@ -15,6 +17,95 @@ __all__ = (
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15
class Node(Hyperlink):
"""
Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
Arguments:
position: (x, y) coordinates of the box's top left corner
width: Box width
url: Hyperlink URL
color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10)
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra)
x, y = position
# Add the box
dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
self.add(box)
cursor = y + PADDING
# Add text label(s)
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
self.add(text)
@property
def box(self):
return self.elements[0] if self.elements else None
@property
def top_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y']
@property
def bottom_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
class Connector(Group):
"""
Return an SVG group containing a line element and text labels representing a Cable.
Arguments:
color: Cable (line) color
url: Hyperlink URL
labels: Iterable of text labels
"""
def __init__(self, start, url, color, labels=[], **extra):
super().__init__(class_='connector', **extra)
self.start = start
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
self.end = (start[0], start[1] + self.height)
self.color = color or '000000'
# Draw a "shadow" line to give the cable a border
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
self.add(cable_shadow)
# Draw the cable
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
self.add(cable)
# Add link
link = Hyperlink(href=url, target='_blank')
# Add text label(s)
cursor = start[1]
cursor += PADDING * 2
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
self.add(link)
class CableTraceSVG:
@ -25,7 +116,7 @@ class CableTraceSVG:
:param width: Width of the generated image (in pixels)
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, origin, width=400, base_url=None):
def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
self.origin = origin
self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else ''
@ -34,6 +125,11 @@ class CableTraceSVG:
# Center edges on pixels to render sharp borders
self.cursor = OFFSET
# Prep elements lists
self.parent_objects = []
self.terminations = []
self.connectors = []
@property
def center(self):
return self.width / 2
@ -78,95 +174,103 @@ class CableTraceSVG:
# Other parent object
return 'e0e0e0'
def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
def draw_parent_objects(self, obj_list):
"""
Return an SVG Link element containing a Rect and one or more text labels representing a
parent object or cable termination point.
:param width: Box width
:param color: Box fill color
:param url: Hyperlink URL
:param labels: Iterable of text labels
:param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
:param padding_multiplier: Add extra vertical padding (default: 1)
:param radius: Box corner radius (default: 10)
Draw a set of parent objects.
"""
self.cursor -= y_indent
width = self.width / len(obj_list)
for i, obj in enumerate(obj_list):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{obj.get_absolute_url()}',
color=self._get_color(obj),
labels=self._get_labels(obj)
)
self.parent_objects.append(node)
if i + 1 == len(obj_list):
self.cursor += node.box['height']
# Create a hyperlink
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
def draw_terminations(self, terminations):
"""
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
"""
nodes = []
nodes_height = 0
width = self.width / len(terminations)
# Add the box
position = (
OFFSET + (self.width - width) / 2,
self.cursor
for i, term in enumerate(terminations):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term),
labels=self._get_labels(term),
radius=5
)
nodes_height = max(nodes_height, node.box['height'])
nodes.append(node)
self.cursor += nodes_height
self.terminations.extend(nodes)
return nodes
def draw_fanin(self, node, connector):
points = (
node.bottom_center,
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
connector.start,
)
height = PADDING * padding_multiplier \
+ LINE_HEIGHT * len(labels) \
+ PADDING * padding_multiplier
box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
link.add(box)
self.cursor += PADDING * padding_multiplier
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
link.add(text)
def draw_fanout(self, node, connector):
points = (
connector.end,
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
node.top_center,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
self.cursor += PADDING * padding_multiplier
def draw_cable(self, cable):
labels = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
connector = Connector(
start=(self.center + OFFSET, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels
)
return link
self.cursor += connector.height
def _draw_cable(self, color, url, labels):
"""
Return an SVG group containing a line element and text labels representing a Cable.
return connector
:param color: Cable (line) color
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
# Draw a "shadow" line to give the cable a border
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
cable_shadow = Line(start=start, end=end, class_='cable-shadow')
group.add(cable_shadow)
# Draw the cable
cable = Line(start=start, end=end, style=f'stroke: #{color}')
group.add(cable)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def _draw_wirelesslink(self, url, labels):
def draw_wirelesslink(self, wirelesslink):
"""
Draw a line with labels representing a WirelessLink.
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
labels = [
f'Wireless link {wirelesslink}',
wirelesslink.get_status_display()
]
if wirelesslink.ssid:
labels.append(wirelesslink.ssid)
# Draw the wireless link
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
@ -177,7 +281,7 @@ class CableTraceSVG:
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
@ -191,7 +295,7 @@ class CableTraceSVG:
return group
def _draw_attachment(self):
def draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
"""
@ -216,109 +320,63 @@ class CableTraceSVG:
traced_path = self.origin.trace()
# Prep elements list
parent_objects = []
terminations = []
connectors = []
# Iterate through each (term, cable, term) segment in the path
# Iterate through each (terms, cable, terms) segment in the path
for i, segment in enumerate(traced_path):
near_end, connector, far_end = segment
near_ends, links, far_ends = segment
# Near end parent
if i == 0:
# If this is the first segment, draw the originating termination's parent object
parent_object = self._draw_box(
width=self.width,
color=self._get_color(near_end.parent_object),
url=near_end.parent_object.get_absolute_url(),
labels=self._get_labels(near_end.parent_object),
padding_multiplier=2
)
parent_objects.append(parent_object)
self.draw_parent_objects(set(end.parent_object for end in near_ends))
# Near end termination
if near_end is not None:
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
# Near end termination(s)
terminations = self.draw_terminations(near_ends)
# Connector (a Cable or WirelessLink)
if connector is not None:
if links:
link = links[0] # Remove Cable from list
# Cable
if type(connector) is Cable:
connector_labels = [
f'Cable {connector}',
connector.get_status_display()
]
if connector.type:
connector_labels.append(connector.get_type_display())
if connector.length and connector.length_unit:
connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
cable = self._draw_cable(
color=connector.color or '000000',
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(cable)
if type(link) is Cable:
# Account for fan-ins height
if len(near_ends) > 1:
self.cursor += FANOUT_HEIGHT
cable = self.draw_cable(link)
self.connectors.append(cable)
# Draw fan-ins
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(connector) is WirelessLink:
connector_labels = [
f'Wireless link {connector}',
connector.get_status_display()
]
if connector.ssid:
connector_labels.append(connector.ssid)
wirelesslink = self._draw_wirelesslink(
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(wirelesslink)
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
radius=5
)
terminations.append(termination)
# Far end termination(s)
if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
else:
self.draw_terminations(far_ends)
# Far end parent
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end.parent_object),
url=far_end.parent_object.get_absolute_url(),
labels=self._get_labels(far_end.parent_object),
y_indent=PADDING,
padding_multiplier=2
)
parent_objects.append(parent_object)
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
elif far_end:
elif far_ends:
# Attachment
attachment = self._draw_attachment()
connectors.append(attachment)
attachment = self.draw_attachment()
self.connectors.append(attachment)
# ProviderNetwork
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
padding_multiplier=2
)
parent_objects.append(parent_object)
self.draw_parent_objects(set(end.parent_object for end in far_ends))
# Determine drawing size
self.drawing = svgwrite.Drawing(
@ -330,7 +388,7 @@ class CableTraceSVG:
self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis)
for element in connectors + parent_objects + terminations:
for element in self.connectors + self.parent_objects + self.terminations:
self.drawing.add(element)
return self.drawing

View File

@ -1,56 +1,109 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.safestring import mark_safe
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
from .template_code import CABLE_LENGTH
__all__ = (
'CableTable',
)
class CableTerminationsColumn(tables.Column):
"""
Args:
cable_end: Which side of the cable to report on (A or B)
attr: The CableTermination attribute to return for each instance (returns the termination object by default)
"""
def __init__(self, cable_end, attr='termination', *args, **kwargs):
self.cable_end = cable_end
self.attr = attr
super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
def _get_terminations(self, manager):
terminations = set()
for cabletermination in manager.all():
if cabletermination.cable_end == self.cable_end:
if termination := getattr(cabletermination, self.attr, None):
terminations.add(termination)
return terminations
def render(self, value):
links = [
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
]
return mark_safe('<br />'.join(links) or '&mdash;')
def value(self, value):
return ','.join([str(t) for t in self._get_terminations(value)])
#
# Cables
#
class CableTable(NetBoxTable):
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
a_terminations = CableTerminationsColumn(
cable_end='A',
orderable=False,
verbose_name='Side A'
)
rack_a = tables.Column(
accessor=Accessor('termination_a__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack A'
)
termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
linkify=True,
verbose_name='Termination A'
)
termination_b_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_b'),
b_terminations = CableTerminationsColumn(
cable_end='B',
orderable=False,
verbose_name='Side B'
verbose_name='Termination B'
)
rack_b = tables.Column(
accessor=Accessor('termination_b__device__rack'),
device_a = CableTerminationsColumn(
cable_end='A',
attr='_device',
orderable=False,
verbose_name='Device A'
)
device_b = CableTerminationsColumn(
cable_end='B',
attr='_device',
orderable=False,
verbose_name='Device B'
)
location_a = CableTerminationsColumn(
cable_end='A',
attr='_location',
orderable=False,
verbose_name='Location A'
)
location_b = CableTerminationsColumn(
cable_end='B',
attr='_location',
orderable=False,
verbose_name='Location B'
)
rack_a = CableTerminationsColumn(
cable_end='A',
attr='_rack',
orderable=False,
verbose_name='Rack A'
)
rack_b = CableTerminationsColumn(
cable_end='B',
attr='_rack',
orderable=False,
linkify=True,
verbose_name='Rack B'
)
termination_b = tables.Column(
accessor=Accessor('termination_b'),
site_a = CableTerminationsColumn(
cable_end='A',
attr='_site',
orderable=False,
linkify=True,
verbose_name='Termination B'
verbose_name='Site A'
)
site_b = CableTerminationsColumn(
cable_end='B',
attr='_site',
orderable=False,
verbose_name='Site B'
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
@ -66,10 +119,10 @@ class CableTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'color', 'length', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type',
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
)

View File

@ -13,15 +13,17 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
{% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %}
"""
# CABLE_TERMINATION_PARENT = """
# {% with value.0 as termination %}
# {% if termination.device %}
# <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
# {% elif termination.circuit %}
# <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
# {% elif termination.power_panel %}
# <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
# {% endif %}
# {% endwith %}
# """
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
@ -133,9 +135,9 @@ CONSOLEPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@ -165,9 +167,9 @@ CONSOLESERVERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@ -197,8 +199,8 @@ POWERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.poweroutlet&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerfeed&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% else %}
@ -224,7 +226,7 @@ POWEROUTLET_BUTTONS = """
<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>
{% if not record.mark_connected %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&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 %}
@ -274,10 +276,10 @@ INTERFACE_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@ -313,12 +315,12 @@ FRONTPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@ -350,12 +352,12 @@ REARPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuitterminations&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}

View File

@ -45,7 +45,7 @@ class Mixins:
device=peer_device,
name='Peer Termination'
)
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@ -55,9 +55,9 @@ class Mixins:
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], obj.name)
self.assertEqual(segment1[0][0]['name'], obj.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], peer_obj.name)
self.assertEqual(segment1[2][0]['name'], peer_obj.name)
class RegionTest(APIViewTestCases.APIViewTestCase):
@ -1884,33 +1884,33 @@ class CableTest(APIViewTestCases.APIViewTestCase):
Interface.objects.bulk_create(interfaces)
cables = (
Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'),
Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'),
Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'),
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
)
for cable in cables:
cable.save()
cls.create_data = [
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[4].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[14].pk,
'a_terminations_type': 'dcim.interface',
'a_terminations': [interfaces[4].pk],
'b_terminations_type': 'dcim.interface',
'b_terminations': [interfaces[14].pk],
'label': 'Cable 4',
},
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[5].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[15].pk,
'a_terminations_type': 'dcim.interface',
'a_terminations': [interfaces[5].pk],
'b_terminations_type': 'dcim.interface',
'b_terminations': [interfaces[15].pk],
'label': 'Cable 5',
},
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[6].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[16].pk,
'a_terminations_type': 'dcim.interface',
'a_terminations': [interfaces[6].pk],
'b_terminations_type': 'dcim.interface',
'b_terminations': [interfaces[16].pk],
'label': 'Cable 6',
},
]
@ -1936,7 +1936,7 @@ class ConnectedDeviceTest(APITestCase):
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

File diff suppressed because it is too large Load Diff

View File

@ -1950,8 +1950,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsolePort.objects.bulk_create(console_ports)
# Cables
Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save()
Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save()
Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save()
# Third port is not connected
def test_name(self):
@ -2097,8 +2097,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsoleServerPort.objects.bulk_create(console_server_ports)
# Cables
Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save()
Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save()
Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save()
# Third port is not connected
def test_name(self):
@ -2244,8 +2244,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort.objects.bulk_create(power_ports)
# Cables
Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save()
Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save()
Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save()
# Third port is not connected
def test_name(self):
@ -2399,8 +2399,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet.objects.bulk_create(power_outlets)
# Cables
Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save()
# Third port is not connected
def test_name(self):
@ -2656,8 +2656,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Interface.objects.bulk_create(interfaces)
# Cables
Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
# Third pair is not connected
def test_name(self):
@ -2932,8 +2932,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
FrontPort.objects.bulk_create(front_ports)
# Cables
Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save()
Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save()
# Third port is not connected
def test_name(self):
@ -3078,8 +3078,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports)
# Cables
Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save()
Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save()
Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save()
# Third port is not connected
def test_name(self):
@ -3663,6 +3663,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', site=sites[0], slug='location-1'),
Location(name='Location 2', site=sites[1], slug='location-1'),
Location(name='Location 3', site=sites[2], slug='location-1'),
)
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], location=locations[0]),
Rack(name='Rack 2', site=sites[1], location=locations[1]),
Rack(name='Rack 3', site=sites[2], location=locations[2]),
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@ -3670,24 +3685,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2),
)
Device.objects.bulk_create(devices)
@ -3711,13 +3719,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@ -3759,6 +3767,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'location': [locations[0].name, locations[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
@ -3780,7 +3795,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
interface_ids = CableTermination.objects.filter(
cable__in=Cable.objects.all()[:3],
cable_end='A'
).values_list('termination_id', flat=True)
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
@ -3924,8 +3942,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort(device=device, name='Power Port 2'),
]
PowerPort.objects.bulk_create(power_ports)
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save()
def test_name(self):
params = {'name': ['Power Feed 1', 'Power Feed 2']}

View File

@ -457,7 +457,7 @@ class CableTestCase(TestCase):
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
self.cable.save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
@ -493,12 +493,14 @@ class CableTestCase(TestCase):
"""
When a new Cable is created, it must be cached on either termination point.
"""
interface1 = Interface.objects.get(pk=self.interface1.pk)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1)
self.assertEqual(interface1._link_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2)
self.assertEqual(interface2._link_peer, interface1)
self.interface1.refresh_from_db()
self.interface2.refresh_from_db()
self.assertEqual(self.interface1.cable, self.cable)
self.assertEqual(self.interface2.cable, self.cable)
self.assertEqual(self.interface1.cable_end, 'A')
self.assertEqual(self.interface2.cable_end, 'B')
self.assertEqual(self.interface1.link_peers, [self.interface2])
self.assertEqual(self.interface2.link_peers, [self.interface1])
def test_cable_deletion(self):
"""
@ -510,50 +512,33 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._link_peer)
self.assertListEqual(interface1.link_peers, [])
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable)
self.assertIsNone(interface2._link_peer)
self.assertListEqual(interface2.link_peers, [])
def test_cabletermination_deletion(self):
def test_cable_validates_same_parent_object(self):
"""
When a CableTermination object is deleted, its attached Cable (if any) must also be deleted.
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
"""
self.interface1.delete()
cable = Cable.objects.filter(pk=self.cable.pk).first()
self.assertIsNone(cable)
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_validates_same_type(self):
"""
The clean method should ensure that all terminations at either end of a Cable are of the same type.
"""
cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_validates_compatible_types(self):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
# An interface cannot be connected to a power port
cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_have_the_same_terminination_on_both_ends(self):
"""
A cable cannot be made with the same A and B side terminations
"""
cable = Cable(termination_a=self.interface1, termination_b=self.interface1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
"""
A cable cannot connect a front port to its corresponding rear port
"""
cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_terminate_to_an_existing_connection(self):
"""
Either side of a cable cannot be terminated when that side already has a connection
"""
# Try to create a cable with the same interface terminations
cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
# An interface cannot be connected to a power port, for example
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
with self.assertRaises(ValidationError):
cable.clean()
@ -561,45 +546,16 @@ class CableTestCase(TestCase):
"""
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
"""
cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3)
cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
with self.assertRaises(ValidationError):
cable.clean()
def test_rearport_connections(self):
"""
Test various combinations of RearPort connections.
"""
# Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
# Connecting a single-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
# Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
# Connecting a two-position RearPort to a three-position RearPort is NOT ok
with self.assertRaises(
ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self):
"""
A cable cannot terminate to a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
with self.assertRaises(ValidationError):
cable.clean()
@ -608,6 +564,6 @@ class CableTestCase(TestCase):
A cable cannot terminate to a wireless interface
"""
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
with self.assertRaises(ValidationError):
cable.clean()

View File

@ -1961,7 +1961,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleport.device,
name='Console Server Port 1'
)
Cable(termination_a=consoleport, termination_b=consoleserverport).save()
Cable(a_terminations=[consoleport], b_terminations=[consoleserverport]).save()
response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
self.assertHttpStatus(response, 200)
@ -2017,7 +2017,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleserverport.device,
name='Console Port 1'
)
Cable(termination_a=consoleserverport, termination_b=consoleport).save()
Cable(a_terminations=[consoleserverport], b_terminations=[consoleport]).save()
response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
self.assertHttpStatus(response, 200)
@ -2079,7 +2079,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=powerport.device,
name='Power Outlet 1'
)
Cable(termination_a=powerport, termination_b=poweroutlet).save()
Cable(a_terminations=[powerport], b_terminations=[poweroutlet]).save()
response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
self.assertHttpStatus(response, 200)
@ -2144,7 +2144,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
def test_trace(self):
poweroutlet = PowerOutlet.objects.first()
powerport = PowerPort.objects.first()
Cable(termination_a=poweroutlet, termination_b=powerport).save()
Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save()
response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
self.assertHttpStatus(response, 200)
@ -2268,7 +2268,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2]
Cable(termination_a=interface1, termination_b=interface2).save()
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
self.assertHttpStatus(response, 200)
@ -2339,7 +2339,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=frontport.device,
name='Interface 1'
)
Cable(termination_a=frontport, termination_b=interface).save()
Cable(a_terminations=[frontport], b_terminations=[interface]).save()
response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
self.assertHttpStatus(response, 200)
@ -2397,7 +2397,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=rearport.device,
name='Interface 1'
)
Cable(termination_a=rearport, termination_b=interface).save()
Cable(a_terminations=[rearport], b_terminations=[interface]).save()
response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
self.assertHttpStatus(response, 200)
@ -2630,19 +2630,18 @@ class CableTestCase(
)
Interface.objects.bulk_create(interfaces)
Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = {
# TODO: Revisit this limitation
# Changing terminations not supported when editing an existing Cable
'termination_a_type': interface_ct.pk,
'termination_a_id': interfaces[0].pk,
'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk,
'a_terminations': interfaces[0].pk,
'b_terminations': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6,
'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label',
@ -2864,7 +2863,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
device=device,
name='Power Port 1'
)
Cable(termination_a=powerfeed, termination_b=powerport).save()
Cable(a_terminations=[powerfeed], b_terminations=[powerport]).save()
response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
self.assertHttpStatus(response, 200)

View File

@ -294,7 +294,6 @@ urlpatterns = [
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
@ -310,7 +309,6 @@ urlpatterns = [
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@ -326,7 +324,6 @@ urlpatterns = [
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@ -342,7 +339,6 @@ urlpatterns = [
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@ -358,7 +354,6 @@ urlpatterns = [
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@ -374,7 +369,6 @@ urlpatterns = [
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@ -390,7 +384,6 @@ urlpatterns = [
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Module bays
@ -447,6 +440,7 @@ urlpatterns = [
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
path('cables/add/', views.CableEditView.as_view(), name='cable_add'),
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
@ -500,6 +494,5 @@ urlpatterns = [
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
]

View File

@ -1,3 +1,5 @@
import itertools
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
@ -29,27 +31,29 @@ def path_node_to_object(repr):
return ct.model_class().objects.get(pk=object_id)
def create_cablepath(node):
def create_cablepath(terminations):
"""
Create CablePaths for all paths originating from the specified node.
Create CablePaths for all paths originating from the specified set of nodes.
:param terminations: Iterable of CableTermination objects
"""
from dcim.models import CablePath
cp = CablePath.from_origin(node)
cp = CablePath.from_origin(terminations)
if cp:
cp.save()
def rebuild_paths(obj):
def rebuild_paths(terminations):
"""
Rebuild all CablePaths which traverse the specified node
Rebuild all CablePaths which traverse the specified nodes.
"""
from dcim.models import CablePath
cable_paths = CablePath.objects.filter(path__contains=obj)
for obj in terminations:
cable_paths = CablePath.objects.filter(_nodes__contains=obj)
with transaction.atomic():
for cp in cable_paths:
cp.delete()
if cp.origin:
create_cablepath(cp.origin)
with transaction.atomic():
for cp in cable_paths:
cp.delete()
create_cablepath(cp.origins)

View File

@ -12,7 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.views.generic import View
from circuits.models import Circuit
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import *
CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort,
'dcim.consoleserverport': ConsoleServerPort,
'dcim.powerport': PowerPort,
'dcim.poweroutlet': PowerOutlet,
'dcim.interface': Interface,
'dcim.frontport': FrontPort,
'dcim.rearport': RearPort,
'dcim.powerfeed': PowerFeed,
'circuits.circuittermination': CircuitTermination,
}
class DeviceComponentsView(generic.ObjectChildrenView):
queryset = Device.objects.all()
@ -1717,7 +1729,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
def get_extra_context(self, request, instance):
interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
'_path__destination'
'_path'
).exclude(
type__in=NONCONNECTABLE_IFACE_TYPES
)
@ -2744,7 +2756,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
#
class CableListView(generic.ObjectListView):
queryset = Cable.objects.all()
queryset = Cable.objects.prefetch_related(
'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
'terminations___site',
)
filterset = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm
table = tables.CableTable
@ -2777,7 +2792,7 @@ class PathTraceView(generic.ObjectView):
# Otherwise, find all CablePaths which traverse the specified object
else:
related_paths = CablePath.objects.filter(path__contains=instance).prefetch_related('origin')
related_paths = CablePath.objects.filter(_nodes__contains=instance)
# Check for specification of a particular path (when tracing pass-through ports)
try:
path_id = int(request.GET.get('cablepath_id'))
@ -2798,8 +2813,8 @@ class PathTraceView(generic.ObjectView):
total_length, is_definitive = path.get_total_length() if path else (None, False)
# Determine the path to the SVG trace image
api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace"
svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg"
api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace"
svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg"
return {
'path': path,
@ -2810,77 +2825,38 @@ class PathTraceView(generic.ObjectView):
}
class CableCreateView(generic.ObjectEditView):
class CableEditView(generic.ObjectEditView):
queryset = Cable.objects.all()
template_name = 'dcim/cable_connect.html'
template_name = 'dcim/cable_edit.html'
def dispatch(self, request, *args, **kwargs):
# Set the form class based on the type of component being connected
self.form = {
'console-port': forms.ConnectCableToConsolePortForm,
'console-server-port': forms.ConnectCableToConsoleServerPortForm,
'power-port': forms.ConnectCableToPowerPortForm,
'power-outlet': forms.ConnectCableToPowerOutletForm,
'interface': forms.ConnectCableToInterfaceForm,
'front-port': forms.ConnectCableToFrontPortForm,
'rear-port': forms.ConnectCableToRearPortForm,
'power-feed': forms.ConnectCableToPowerFeedForm,
'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
}[kwargs.get('termination_b_type')]
# If creating a new Cable, initialize the form class using URL query params
if 'pk' not in kwargs:
self.form = forms.get_cable_form(
a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
)
return super().dispatch(request, *args, **kwargs)
def get_object(self, **kwargs):
# Always return a new instance
return self.queryset.model()
"""
Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView
doesn't currently provide a hook for dynamic class resolution.
"""
obj = super().get_object(**kwargs)
def alter_object(self, obj, request, url_args, url_kwargs):
termination_a_type = url_kwargs.get('termination_a_type')
termination_a_id = url_kwargs.get('termination_a_id')
termination_b_type_name = url_kwargs.get('termination_b_type')
self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
# Initialize Cable termination attributes
obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
obj.termination_b_type = self.termination_b_type
if obj.pk:
# TODO: Optimize this logic
termination_a = obj.terminations.filter(cable_end='A').first()
a_type = termination_a.termination._meta.model if termination_a else None
termination_b = obj.terminations.filter(cable_end='B').first()
b_type = termination_b.termination._meta.model if termination_a else None
self.form = forms.get_cable_form(a_type, b_type)
return obj
def get(self, request, *args, **kwargs):
obj = self.get_object(**kwargs)
obj = self.alter_object(obj, request, args, kwargs)
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
if termination_a_site and 'termination_b_region' not in initial_data:
initial_data['termination_b_region'] = termination_a_site.region
if termination_a_site and 'termination_b_site_group' not in initial_data:
initial_data['termination_b_site_group'] = termination_a_site.group
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = termination_a_site
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
form = self.form(instance=obj, initial=initial_data)
return render(request, self.template_name, {
'obj': obj,
'obj_type': Cable._meta.verbose_name,
'termination_b_type': self.termination_b_type.name,
'form': form,
'return_url': self.get_return_url(request, obj),
})
class CableEditView(generic.ObjectEditView):
queryset = Cable.objects.all()
form = forms.CableForm
template_name = 'dcim/cable_edit.html'
class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all()
@ -2893,14 +2869,14 @@ class CableBulkImportView(generic.BulkImportView):
class CableBulkEditView(generic.BulkEditView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
queryset = Cable.objects.prefetch_related('terminations')
filterset = filtersets.CableFilterSet
table = tables.CableTable
form = forms.CableBulkEditForm
class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
queryset = Cable.objects.prefetch_related('terminations')
filterset = filtersets.CableFilterSet
table = tables.CableTable

View File

@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware:
def process_exception(self, request, exception):
# Handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
# if is_api_request(request):
# return rest_api_server_error(request)
# Don't catch exceptions when in debug mode
if settings.DEBUG:

View File

@ -3,7 +3,6 @@ import sys
from django.conf import settings
from django.core.cache import cache
from django.db.models import F
from django.http import HttpResponseServerError
from django.shortcuts import redirect, render
from django.template import loader
@ -37,14 +36,13 @@ class HomeView(View):
return redirect("login")
connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__destination_id__isnull=False
_path__is_complete=True
)
connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__destination_id__isnull=False
_path__is_complete=True
)
connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__destination_id__isnull=False,
pk__lt=F('_path__destination_id')
_path__is_complete=True
)
def build_stats():

Binary file not shown.

View File

@ -55,7 +55,11 @@ svg {
line {
stroke-width: 5px;
}
line.cable-shadow {
polyline {
fill: none;
stroke-width: 5px;
}
.cable-shadow {
stroke: var(--nbx-trace-cable-shadow);
stroke-width: 7px;
}

View File

@ -44,16 +44,15 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">Marked as connected</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
{% with peer=termination.get_link_peer %}
to
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> to
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}
{% endwith %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> Trace
@ -70,10 +69,10 @@
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
</ul>
</div>
{% endif %}

View File

@ -5,85 +5,79 @@
{% load plugins %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Cable
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Type</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Status</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Color</th>
<td>
{% if object.color %}
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>
{% if object.length %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Termination A
</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
</div>
</div>
<div class="card">
<h5 class="card-header">
Termination B
</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
</div>
</div>
{% plugin_right_page object %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Cable</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Type</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Status</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Color</th>
<td>
{% if object.color %}
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>
{% if object.length %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Termination A</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with terminations=object.get_a_terminations %}
</div>
</div>
<div class="card">
<h5 class="card-header">Termination B</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with terminations=object.get_b_terminations %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -1,186 +0,0 @@
{% extends 'base/layout.html' %}
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
</li>
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% with termination_a=form.instance.termination_a %}
{% render_errors form %}
<form method="post">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row my-3">
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header offset-sm-3">A Side</h5>
<div class="card-body">
{% if termination_a.device %}
{# Device component #}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Region</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site Group</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.site.group }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.site }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Location</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.location|default:"None" }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Rack</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.rack|default:"None" }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Device</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Type</label>
<div class="col">
<input class="form-control" value="{{ termination_a|meta:"verbose_name"|capfirst }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Name</label>
<div class="col">
<input class="form-control" value="{{ termination_a }}" disabled />
</div>
</div>
{% else %}
{# Circuit termination #}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Site</label>
<div class="col">
<input class="form-control" value="{{ termination_a.site }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Provider</label>
<div class="col">
<input class="form-control" value="{{ termination_a.circuit.provider }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Circuit</label>
<div class="col">
<input class="form-control" value="{{ termination_a.circuit.cid }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Side</label>
<div class="col">
<input class="form-control" value="{{ termination_a.term_side }}" disabled />
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col col-md-2 flex-column justify-content-center align-items-center d-none d-md-flex">
<i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
</div>
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header offset-sm-3">B Side</h5>
<div class="card-body">
{% if tabs %}
<ul class="nav nav-tabs">
{% for url, link in tabs %}
<li class="nav-item" role="presentation">
<a class="nav-link" href="{{ url }}">{{ link }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if 'termination_b_provider' in form.fields %}
{% render_field form.termination_b_provider %}
{% endif %}
{% if 'termination_b_region' in form.fields %}
{% render_field form.termination_b_region %}
{% endif %}
{% if 'termination_b_sitegroup' in form.fields %}
{% render_field form.termination_b_sitegroup %}
{% endif %}
{% if 'termination_b_site' in form.fields %}
{% render_field form.termination_b_site %}
{% endif %}
{% if 'termination_b_location' in form.fields %}
{% render_field form.termination_b_location %}
{% endif %}
{% if 'termination_b_rack' in form.fields %}
{% render_field form.termination_b_rack %}
{% endif %}
{% if 'termination_b_device' in form.fields %}
{% render_field form.termination_b_device %}
{% endif %}
{% if 'termination_b_type' in form.fields %}
{% render_field form.termination_b_type %}
{% endif %}
{% if 'termination_b_powerpanel' in form.fields %}
{% render_field form.termination_b_powerpanel %}
{% endif %}
{% if 'termination_b_circuit' in form.fields %}
{% render_field form.termination_b_circuit %}
{% endif %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Type</label>
<div class="col">
<input class="form-control" value="{{ termination_b_type|capfirst }}" disabled />
</div>
</div>
{% render_field form.termination_b_id %}
</div>
</div>
</div>
</div>
<div class="row my-3 justify-content-center">
<div class="col col-md-8">
<div class="card">
<h5 class="card-header offset-sm-3">Cable</h5>
<div class="card-body">
{% include 'dcim/inc/cable_form.html' %}
</div>
</div>
</div>
</div>
<div class="row my-3">
<div class="col col-md-12 text-center">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
</div>
</div>
</form>
{% endwith %}
</div>
{% endblock %}

View File

@ -1,5 +1,125 @@
{% extends 'generic/object_edit.html' %}
{% extends 'base/layout.html' %}
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% block form %}
{% include 'dcim/inc/cable_form.html' %}
{% block title %}Connect Cable{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
</li>
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% render_errors form %}
<form method="post">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row my-3">
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header offset-sm-3">A Side</h5>
<div class="card-body">
{% render_field form.termination_a_region %}
{% render_field form.termination_a_sitegroup %}
{% render_field form.termination_a_site %}
{% render_field form.termination_a_location %}
{% if 'termination_a_rack' in form.fields %}
{% render_field form.termination_a_rack %}
{% endif %}
{% if 'termination_a_device' in form.fields %}
{% render_field form.termination_a_device %}
{% endif %}
{% if 'termination_a_powerpanel' in form.fields %}
{% render_field form.termination_a_powerpanel %}
{% endif %}
{% if 'termination_a_provider' in form.fields %}
{% render_field form.termination_a_provider %}
{% endif %}
{% if 'termination_a_circuit' in form.fields %}
{% render_field form.termination_a_circuit %}
{% endif %}
{% render_field form.a_terminations %}
</div>
</div>
</div>
<div class="col col-md-2 flex-column justify-content-center align-items-center d-none d-md-flex">
<i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
</div>
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header offset-sm-3">B Side</h5>
<div class="card-body">
{% render_field form.termination_b_region %}
{% render_field form.termination_b_sitegroup %}
{% render_field form.termination_b_site %}
{% render_field form.termination_b_location %}
{% if 'termination_b_rack' in form.fields %}
{% render_field form.termination_b_rack %}
{% endif %}
{% if 'termination_b_device' in form.fields %}
{% render_field form.termination_b_device %}
{% endif %}
{% if 'termination_b_powerpanel' in form.fields %}
{% render_field form.termination_b_powerpanel %}
{% endif %}
{% if 'termination_b_provider' in form.fields %}
{% render_field form.termination_b_provider %}
{% endif %}
{% if 'termination_b_circuit' in form.fields %}
{% render_field form.termination_b_circuit %}
{% endif %}
{% render_field form.b_terminations %}
</div>
</div>
</div>
</div>
<div class="row my-3 justify-content-center">
<div class="col col-md-8">
<div class="card">
<h5 class="card-header offset-sm-3">Cable</h5>
<div class="card-body">
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.label %}
{% render_field form.color %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
</div>
<div class="col-md-4">
{{ form.length_unit }}
</div>
<div class="invalid-feedback"></div>
</div>
{% render_field form.tags %}
{% if form.custom_fields %}
<div class="field-group">
<div class="row mb-3">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row my-3">
<div class="col col-md-12 text-center">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -111,28 +111,13 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}"
>
Console Server Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
</li>
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
>
Front Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
</li>
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
>
Rear Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
</li>
</ul>
</div>

View File

@ -113,28 +113,13 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='console-port' %}?return_url={{ object.get_absolute_url }}"
>
Console Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
</li>
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
>
Front Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
</li>
<li>
<a
class="dropdown-item"
href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
>
Rear Port
</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
</li>
</ul>
</div>

View File

@ -105,22 +105,22 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}">Console Server Port</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}">Console Server Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='console-port' %}?return_url={{ object.get_absolute_url }}">Console Port</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}">Console Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
</li>
</ul>
</div>

View File

@ -1,27 +0,0 @@
{% load form_helpers %}
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.label %}
{% render_field form.color %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
</div>
<div class="col-md-4">
{{ form.length_unit }}
</div>
<div class="invalid-feedback"></div>
</div>
{% render_field form.tags %}
{% if form.custom_fields %}
<div class="field-group">
<div class="row mb-3">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}

View File

@ -1,42 +1,58 @@
{% load helpers %}
<table class="table table-hover panel-body attr-table">
{% if termination.device %}
{# Device component #}
<tr>
<td>Device</td>
<td>{{ termination.device|linkify }}</td>
</tr>
<tr>
<td>Site</td>
<td>{{ termination.device.site|linkify }}</td>
</tr>
{% if termination.device.rack %}
<tr>
<td>Rack</td>
<td>{{ termination.device.rack|linkify }}</td>
</tr>
{% endif %}
<tr>
<td>Type</td>
<td>{{ termination|meta:"verbose_name"|capfirst }}</td>
</tr>
<tr>
<td>Component</td>
<td>{{ termination|linkify }}</td>
</tr>
{% else %}
{# Circuit termination #}
<tr>
<td>Provider</td>
<td>{{ termination.circuit.provider|linkify }}</td>
</tr>
<tr>
<td>Circuit</td>
<td>{{ termination.circuit|linkify }}</td>
</tr>
<tr>
<td>Termination</td>
<td>{{ termination }}</td>
</tr>
{% endif %}
{% if terminations.0.device %}
{# Device component #}
<tr>
<td>Site</td>
<td>{{ terminations.0.device.site|linkify }}</td>
</tr>
<tr>
<td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
</tr>
<tr>
<td>Device</td>
<td>{{ terminations.0.device|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% elif terminations.0.power_panel %}
{# Power feed #}
<tr>
<td>Site</td>
<td>{{ terminations.0.power_panel.site|linkify }}</td>
</tr>
<tr>
<td>Power Panel</td>
<td>{{ terminations.0.power_panel|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% else %}
{# Circuit termination #}
<tr>
<td>Provider</td>
<td>{{ terminations.0.circuit.provider|linkify }}</td>
</tr>
<tr>
<td>Circuit</td>
<td>
{% for term in terminations %}
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
</table>

View File

@ -263,24 +263,16 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">
Interface
</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">
Front Port
</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">
Rear Port
</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">
Circuit Termination
</a>
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
</li>
</ul>
</div>

View File

@ -158,8 +158,7 @@
{% if not object.mark_connected and not object.cable %}
<div class="card-footer">
{% if perms.dcim.add_cable %}
<a href="{% url 'dcim:powerfeed_connect' termination_a_id=object.pk termination_b_type='power-port' %}?return_url={{ object.get_absolute_url }}"
class="btn btn-primary btn-sm float-end">
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a>
{% endif %}

View File

@ -111,7 +111,7 @@
<div class="text-muted">
Not Connected
{% if perms.dcim.add_cable %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=object.pk termination_b_type='power-port' %}?return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a>
{% endif %}

View File

@ -117,10 +117,10 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-link" href="{% url 'dcim:powerport_connect' termination_a_id=object.pk termination_b_type='power-outlet' %}?return_url={{ object.get_absolute_url }}">Power Outlet</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
</li>
<li>
<a class="dropdown-link" href="{% url 'dcim:powerport_connect' termination_a_id=object.pk termination_b_type='power-feed' %}?return_url={{ object.get_absolute_url }}">Power Feed</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
</li>
</ul>
</span>

View File

@ -101,16 +101,16 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a>
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
</li>
<li>
<a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a>
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
</li>
<li>
<a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a>
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
</li>
<li>
<a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a>
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
</li>
</ul>
</span>

View File

@ -25,18 +25,16 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs):
if instance.interface_a.wireless_link != instance:
logger.debug(f"Updating interface A for wireless link {instance}")
instance.interface_a.wireless_link = instance
instance.interface_a._link_peer = instance.interface_b
instance.interface_a.save()
if instance.interface_b.cable != instance:
logger.debug(f"Updating interface B for wireless link {instance}")
instance.interface_b.wireless_link = instance
instance.interface_b._link_peer = instance.interface_a
instance.interface_b.save()
# Create/update cable paths
if created:
for interface in (instance.interface_a, instance.interface_b):
create_cablepath(interface)
create_cablepath([interface])
@receiver(post_delete, sender=WirelessLink)
@ -48,19 +46,11 @@ def nullify_connected_interfaces(instance, **kwargs):
if instance.interface_a is not None:
logger.debug(f"Nullifying interface A for wireless link {instance}")
Interface.objects.filter(pk=instance.interface_a.pk).update(
wireless_link=None,
_link_peer_type=None,
_link_peer_id=None
)
Interface.objects.filter(pk=instance.interface_a.pk).update(wireless_link=None)
if instance.interface_b is not None:
logger.debug(f"Nullifying interface B for wireless link {instance}")
Interface.objects.filter(pk=instance.interface_b.pk).update(
wireless_link=None,
_link_peer_type=None,
_link_peer_id=None
)
Interface.objects.filter(pk=instance.interface_b.pk).update(wireless_link=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
cablepath.delete()