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. * 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). * 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 ### New Features
@ -19,6 +41,8 @@
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) #### 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 ### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@ -55,23 +79,83 @@
### REST API Changes ### REST API Changes
* Added the following endpoints: * Added the following endpoints:
* `/api/dcim/cable-terminations/`
* `/api/ipam/l2vpns/` * `/api/ipam/l2vpns/`
* `/api/ipam/l2vpn-terminations/` * `/api/ipam/l2vpn-terminations/`
* circuits.Circuit * circuits.Circuit
* Added optional `termination_date` field * Added optional `termination_date` field
* circuits.CircuitTermination * 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 * dcim.Device
* The `position` field has been changed from an integer to a decimal * The `position` field has been changed from an integer to a decimal
* dcim.DeviceType * dcim.DeviceType
* The `u_height` field has been changed from an integer to a decimal * 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 * 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 optional `poe_mode` and `poe_type` fields
* Added the `l2vpn_termination` read-only field * Added the `l2vpn_termination` read-only field
* dcim.Location * dcim.Location
* Added required `status` field (default value: `active`) * 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 * dcim.Rack
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * 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 * extras.ConfigContext
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
* extras.CustomField * extras.CustomField

View File

@ -3,11 +3,11 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer 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.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer from ipam.api.nested_serializers import NestedASNSerializer
from netbox.api import ChoiceField, SerializedPKRelatedField 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 tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import * 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') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True) provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', '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', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'_occupied', 'tags', 'custom_fields', 'created', 'last_updated', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]

View File

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

View File

@ -1,7 +1,7 @@
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
).distinct() ).distinct()
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet): class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSe
class Meta: class Meta:
model = CircuitTermination 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -1,4 +1,5 @@
from circuits import filtersets, models from circuits import filtersets, models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType 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: class Meta:
model = models.CircuitTermination 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('extras', '0076_configcontext_locations'), ('circuits', '0035_provider_asns'),
('circuits', '0036_circuit_termination_date'),
] ]
operations = [ operations = [
migrations.AddField(
model_name='circuit',
name='termination_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField( migrations.AddField(
model_name='circuittermination', model_name='circuittermination',
name='custom_field_data', 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 django.urls import reverse
from circuits.choices import * from circuits.choices import *
from dcim.models import LinkTermination from dcim.models import CabledObjectModel
from netbox.models import ( from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
) )
@ -149,7 +149,7 @@ class CircuitTermination(
TagsMixin, TagsMixin,
WebhooksMixin, WebhooksMixin,
ChangeLoggedModel, ChangeLoggedModel,
LinkTermination CabledObjectModel
): ):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',

View File

@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
if not raw: if not raw:
peer_termination = instance.get_peer_termination() peer_termination = instance.get_peer_termination()
if 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) 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): def test_term_side(self):
params = {'term_side': 'A'} params = {'term_side': 'A'}

View File

@ -246,7 +246,7 @@ class CircuitTerminationTestCase(
device=device, device=device,
name='Interface 1' 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})) response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,279 +1,171 @@
from django import forms
from circuits.models import Circuit, CircuitTermination, Provider from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import * from dcim.models import *
from extras.models import Tag from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from netbox.forms import NetBoxModelForm from .models import CableForm
from tenancy.forms import TenancyForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
'ConnectCableToConsolePortForm',
'ConnectCableToConsoleServerPortForm',
'ConnectCableToFrontPortForm',
'ConnectCableToInterfaceForm',
'ConnectCableToPowerFeedForm',
'ConnectCableToPowerPortForm',
'ConnectCableToPowerOutletForm',
'ConnectCableToRearPortForm',
)
class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): def get_cable_form(a_type, b_type):
"""
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',
}
)
class Meta: class FormMetaclass(forms.models.ModelFormMetaclass):
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,
}
def clean_termination_b_id(self): def __new__(mcs, name, bases, attrs):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
termination_b_id = DynamicModelChoiceField( queryset=Region.objects.all(),
queryset=ConsolePort.objects.all(), label='Region',
label='Name', required=False,
disabled_indicator='_occupied', initial_params={
query_params={ 'sites': f'$termination_{cable_end}_site'
'device_id': '$termination_b_device' }
} )
) 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): attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
termination_b_id = DynamicModelChoiceField( queryset=Rack.objects.all(),
queryset=ConsoleServerPort.objects.all(), label='Rack',
label='Name', required=False,
disabled_indicator='_occupied', null_option='None',
query_params={ initial_params={
'device_id': '$termination_b_device' '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): attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
termination_b_id = DynamicModelChoiceField( queryset=PowerPanel.objects.all(),
queryset=PowerPort.objects.all(), label='Power Panel',
label='Name', required=False,
disabled_indicator='_occupied', initial_params={
query_params={ 'powerfeeds__in': f'${cable_end}_terminations'
'device_id': '$termination_b_device' },
} 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): attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
termination_b_id = DynamicModelChoiceField( queryset=Provider.objects.all(),
queryset=PowerOutlet.objects.all(), label='Provider',
label='Name', initial_params={
disabled_indicator='_occupied', 'circuits': f'$termination_{cable_end}_circuit'
query_params={ },
'device_id': '$termination_b_device' 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): class _CableForm(CableForm, metaclass=FormMetaclass):
termination_b_id = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device',
'kind': 'physical',
}
)
def __init__(self, *args, **kwargs):
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
termination_b_id = DynamicModelChoiceField( for field_name in ('a_terminations', 'b_terminations'):
queryset=FrontPort.objects.all(), if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
label='Name', kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
super().__init__(*args, **kwargs)
class ConnectCableToRearPortForm(ConnectCableToDeviceForm): if self.instance and self.instance.pk:
termination_b_id = DynamicModelChoiceField( # Initialize A/B terminations when modifying an existing Cable instance
queryset=RearPort.objects.all(), self.initial['a_terminations'] = self.instance.get_a_terminations()
label='Name', self.initial['b_terminations'] = self.instance.get_b_terminations()
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
def save(self, *args, **kwargs):
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): # Set the A/B terminations on the Cable instance
termination_b_provider = DynamicModelChoiceField( self.instance.a_terminations = self.cleaned_data['a_terminations']
queryset=Provider.objects.all(), self.instance.b_terminations = self.cleaned_data['b_terminations']
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'
}
)
class Meta(ConnectCableToDeviceForm.Meta): return super().save(*args, **kwargs)
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',
]
def clean_termination_b_id(self): return _CableForm
# 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)

View File

@ -730,7 +730,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable model = Cable
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (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')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
@ -747,13 +747,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
}, },
label=_('Site') 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( rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
label=_('Rack'), label=_('Rack'),
null_option='None', null_option='None',
query_params={ query_params={
'site_id': '$site_id' 'site_id': '$site_id',
'location_id': '$location_id',
} }
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
@ -761,8 +771,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False, required=False,
query_params={ query_params={
'site_id': '$site_id', 'site_id': '$site_id',
'tenant_id': '$tenant_id', 'location_id': '$location_id',
'rack_id': '$rack_id', 'rack_id': '$rack_id',
'tenant_id': '$tenant_id',
}, },
label=_('Device') 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 ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin
__all__ = ( __all__ = (
'CableType', 'CableType',
@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
return self.length_unit or None 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: class Meta:
model = models.ConsolePort model = models.ConsolePort
@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None return self.type or None
class ConsoleServerPortType(ComponentObjectType): class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.ConsoleServerPort model = models.ConsoleServerPort
@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
return self.airflow or None return self.airflow or None
class FrontPortType(ComponentObjectType): class FrontPortType(ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.FrontPort model = models.FrontPort
@ -219,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType): class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.Interface model = models.Interface
@ -322,7 +331,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType): class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.PowerFeed model = models.PowerFeed
@ -330,7 +339,7 @@ class PowerFeedType(NetBoxObjectType):
filterset_class = filtersets.PowerFeedFilterSet filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType): class PowerOutletType(ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.PowerOutlet model = models.PowerOutlet
@ -366,7 +375,7 @@ class PowerPanelType(NetBoxObjectType):
filterset_class = filtersets.PowerPanelFilterSet filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType): class PowerPortType(ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.PowerPort model = models.PowerPort
@ -418,7 +427,7 @@ class RackRoleType(OrganizationalObjectType):
filterset_class = filtersets.RackRoleFilterSet filterset_class = filtersets.RackRoleFilterSet
class RearPortType(ComponentObjectType): class RearPortType(ComponentObjectType, CabledObjectMixin):
class Meta: class Meta:
model = models.RearPort 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}...') self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0 i = 0
for i, obj in enumerate(origins, start=1): for i, obj in enumerate(origins, start=1):
create_cablepath(obj) create_cablepath([obj])
if not i % 100: if not i % 100:
self.draw_progress_bar(i * 100 / origins_count) self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100) 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 collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType 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 import models
from django.db.models import Sum from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse from django.urls import reverse
from dcim.choices import * 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 dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from netbox.models import NetBoxModel from netbox.models import NetBoxModel
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters from utilities.utils import to_meters
from .devices import Device from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort from .device_components import FrontPort, RearPort
__all__ = ( __all__ = (
'Cable', 'Cable',
'CablePath', 'CablePath',
'CableTermination',
) )
trace_paths = Signal()
# #
# Cables # Cables
# #
@ -32,28 +38,6 @@ class Cable(NetBoxModel):
""" """
A physical connection between two endpoints. 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( type = models.CharField(
max_length=50, max_length=50,
choices=CableTypeChoices, choices=CableTypeChoices,
@ -96,31 +80,11 @@ class Cable(NetBoxModel):
blank=True, blank=True,
null=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: class Meta:
ordering = ['pk'] ordering = ('pk',)
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted # 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 # Cache the original status so we can check later if it's been changed
self._orig_status = self.status self._orig_status = self.status
@classmethod # Assign any *new* CableTerminations for the instance. These will replace any existing
def from_db(cls, db, field_names, values): # terminations on save().
""" if a_terminations is not None:
Cache the original A and B terminations of existing Cable instances for later reference inside clean(). self.a_terminations = a_terminations
""" if b_terminations is not None:
instance = super().from_db(db, field_names, values) self.b_terminations = b_terminations
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
def __str__(self): def __str__(self):
pk = self.pk or self._pk pk = self.pk or self._pk
@ -151,123 +108,41 @@ class Cable(NetBoxModel):
return reverse('dcim:cable', args=[self.pk]) return reverse('dcim:cable', args=[self.pk])
def clean(self): def clean(self):
from circuits.models import CircuitTermination
super().clean() 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 # Validate length and length_unit
if self.length is not None and not self.length_unit: if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length") raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None: elif self.length is None:
self.length_unit = '' 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): def save(self, *args, **kwargs):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering # Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit: if self.length and self.length_unit:
@ -275,199 +150,454 @@ class Cable(NetBoxModel):
else: else:
self._abs_length = None 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) super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.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): def get_status_color(self):
return LinkStatusChoices.colors.get(self.status) 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: assert self.termination is not None
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] # 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): class CablePath(models.Model):
""" """
A CablePath instance represents the physical path from an origin to a destination, including all intermediate A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do including all intermediate elements.
not terminate on a PathEndpoint).
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following terminate to one or more objects.) For example, consider the following
topology: topology:
1 2 3 A B C
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B 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: This path would be expressed as:
CablePath( CablePath(
origin = Interface A path = [
destination = Interface B [Interface 1],
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] [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 `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
"connected". 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( path = models.JSONField(
to=ContentType, default=list
on_delete=models.CASCADE,
related_name='+'
) )
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( is_active = models.BooleanField(
default=False default=False
) )
is_complete = models.BooleanField(
default=False
)
is_split = models.BooleanField( is_split = models.BooleanField(
default=False default=False
) )
_nodes = PathField()
class Meta:
unique_together = ('origin_type', 'origin_id')
def __str__(self): def __str__(self):
status = ' (active)' if self.is_active else ' (split)' if self.is_split else '' return f"Path #{self.pk}: {len(self.path)} hops"
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Save the flattened nodes list
self._nodes = list(itertools.chain(*self.path))
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Record a direct reference to this CablePath on its originating object # Record a direct reference to this CablePath on its originating object(s)
model = self.origin._meta.model origin_model = self.origin_type.model_class()
model.objects.filter(pk=self.origin.pk).update(_path=self.pk) 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 @property
def segment_count(self): def segment_count(self):
total_length = 1 + len(self.path) + (1 if self.destination else 0) return int(len(self.path) / 3)
return int(total_length / 3)
@classmethod @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 from circuits.models import CircuitTermination
if origin is None or origin.link is None:
return None
destination = None
path = [] path = []
position_stack = [] position_stack = []
is_complete = False
is_active = True is_active = True
is_split = False is_split = False
node = origin while terminations:
while node.link is not None:
if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED: # 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 is_active = False
# Follow the link to its far-end termination # Step 4: Determine the far-end terminations
path.append(object_to_path_node(node.link)) if isinstance(link, Cable):
peer_termination = node.get_link_peer() 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 # Step 5: Record the far-end termination object(s)
if isinstance(peer_termination, FrontPort): path.append([
path.append(object_to_path_node(peer_termination)) object_to_path_node(t) for t in remote_terminations
node = peer_termination.rear_port ])
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Follow a RearPort to its corresponding FrontPort (if any) # Step 6: Determine the "next hop" terminations, if applicable
elif isinstance(peer_termination, RearPort): if isinstance(remote_terminations[0], FrontPort):
path.append(object_to_path_node(peer_termination)) # 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 terminations = rear_ports
if peer_termination.positions == 1:
position = 1 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: 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: 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 is_split = True
break break
try: terminations = front_ports
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node)) elif isinstance(remote_terminations[0], CircuitTermination):
except ObjectDoesNotExist: # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
# No corresponding FrontPort found for the RearPort 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 break
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) terminations = [circuit_termination]
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
# Anything else marks the end of the path # Anything else marks the end of the path
else: else:
destination = peer_termination is_complete = True
break break
if destination is None:
is_active = False
return cls( return cls(
origin=origin,
destination=destination,
path=path, path=path,
is_complete=is_complete,
is_active=is_active, is_active=is_active,
is_split=is_split 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. Return the path as a list of prefetched objects.
""" """
# Compile a list of IDs to prefetch for each type of model in the path # Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list) to_prefetch = defaultdict(list)
for node in self.path: for node in self._nodes:
ct_id, object_id = decompile_path_node(node) ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id) to_prefetch[ct_id].append(object_id)
@ -484,19 +614,15 @@ class CablePath(models.Model):
# Replicate the path using the prefetched objects. # Replicate the path using the prefetched objects.
path = [] path = []
for node in self.path: for step in self.path:
ct_id, object_id = decompile_path_node(node) nodes = []
path.append(prefetched[ct_id][object_id]) for node in step:
ct_id, object_id = decompile_path_node(node)
nodes.append(prefetched[ct_id][object_id])
path.append(nodes)
return path 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): def get_cable_ids(self):
""" """
Return all Cable IDs within the path. 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_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = [] cable_ids = []
for node in self.path: for node in self._nodes:
ct, id = decompile_path_node(node) ct, id = decompile_path_node(node)
if ct == cable_ct: if ct == cable_ct:
cable_ids.append(id) cable_ids.append(id)
@ -527,6 +653,6 @@ class CablePath(models.Model):
""" """
Return all available next segments in a split cable path. 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) 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.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType 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.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import MACAddressField, WWNField from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from netbox.models import OrganizationalModel, NetBoxModel from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'LinkTermination', 'CabledObjectModel',
'ConsolePort', 'ConsolePort',
'ConsoleServerPort', 'ConsoleServerPort',
'DeviceBay', 'DeviceBay',
@ -103,14 +104,10 @@ class ModularComponentModel(ComponentModel):
abstract = True 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 An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
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.
""" """
cable = models.ForeignKey( cable = models.ForeignKey(
to='dcim.Cable', to='dcim.Cable',
@ -119,36 +116,21 @@ class LinkTermination(models.Model):
blank=True, blank=True,
null=True null=True
) )
_link_peer_type = models.ForeignKey( cable_end = models.CharField(
to=ContentType, max_length=1,
on_delete=models.SET_NULL,
related_name='+',
blank=True, blank=True,
null=True choices=CableEndChoices
)
_link_peer_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
_link_peer = GenericForeignKey(
ct_field='_link_peer_type',
fk_field='_link_peer_id'
) )
mark_connected = models.BooleanField( mark_connected = models.BooleanField(
default=False, default=False,
help_text="Treat as if a cable is connected" 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. cable_terminations = GenericRelation(
_cabled_as_a = GenericRelation( to='dcim.CableTermination',
to='dcim.Cable', content_type_field='termination_type',
content_type_field='termination_a_type', object_id_field='termination_id',
object_id_field='termination_a_id' related_query_name='%(class)s',
)
_cabled_as_b = GenericRelation(
to='dcim.Cable',
content_type_field='termination_b_type',
object_id_field='termination_b_id'
) )
class Meta: class Meta:
@ -157,22 +139,19 @@ class LinkTermination(models.Model):
def clean(self): def clean(self):
super().clean() 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({ raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached." "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 @property
def link(self): def link(self):
""" """
@ -180,10 +159,31 @@ class LinkTermination(models.Model):
""" """
return self.cable 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): 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. 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 `_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 origin = self
path = [] path = []
# Construct the complete path # Construct the complete path (including e.g. bridged interfaces)
while origin is not None: while origin is not None:
if origin._path is None: if origin._path is None:
break break
path.extend([origin, *origin._path.get_path()]) path.extend(origin._path.path_objects)
while (len(path) + 1) % 3: while (len(path)) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None) # by inserting empty entries immediately prior to the path's destination node(s)
path.append(origin._path.destination) path.append([])
# Check for bridge interface to continue the trace # Check for a bridged relationship to continue the trace
origin = getattr(origin._path.destination, 'bridge', None) 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)) return list(zip(*[iter(path)] * 3))
def get_trace_svg(self, base_url=None, width=None): @cached_property
if width is not None: def connected_endpoints(self):
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):
""" """
Caching accessor for the attached CablePath's destination (if any) Caching accessor for the attached CablePath's destination (if any)
""" """
if not hasattr(self, '_connected_endpoint'): return self._path.destinations if self._path else []
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
# #
# Console components # Console components
# #
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. 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}) 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. 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 # 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. 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)." '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): def get_power_draw(self):
""" """
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. 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 # 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: if self.allocated_draw is None and self.maximum_draw is None:
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) utilization = self.get_downstream_powerports().aggregate(
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(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
) )
ret = { ret = {
'allocated': utilization['allocated_draw_total'] or 0, 'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0, 'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': len(outlet_ids), 'outlet_count': self.poweroutlets.count(),
'legs': [], 'legs': [],
} }
# Calculate per-leg aggregates for three-phase feeds # Calculate per-leg aggregates for three-phase power feeds
if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: 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: for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) utilization = self.get_downstream_powerports(leg=leg).aggregate(
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
) )
@ -385,7 +397,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
'name': leg_name, 'name': leg_name,
'allocated': utilization['allocated_draw_total'] or 0, 'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_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 return ret
@ -394,12 +406,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
return { return {
'allocated': self.allocated_draw or 0, 'allocated': self.allocated_draw or 0,
'maximum': self.maximum_draw or 0, 'maximum': self.maximum_draw or 0,
'outlet_count': PowerOutlet.objects.filter(power_port=self).count(), 'outlet_count': self.poweroutlets.count(),
'legs': [], '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. 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 # Validate power port assignment
if self.power_port and self.power_port.device != self.device: if self.power_port and self.power_port.device != self.device:
raise ValidationError( raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
"Parent power port ({}) must belong to the same device".format(self.power_port)
)
# #
@ -513,7 +523,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count() 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. 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): def link(self):
return self.cable or self.wireless_link 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 @property
def l2vpn_termination(self): def l2vpn_termination(self):
return self.l2vpn_terminations.first() return self.l2vpn_terminations.first()
@ -838,7 +860,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
# Pass-through ports # Pass-through ports
# #
class FrontPort(ModularComponentModel, LinkTermination): class FrontPort(ModularComponentModel, CabledObjectModel):
""" """
A pass-through port on the front of a Device. 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. 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.config import ConfigItem
from netbox.models import NetBoxModel from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint from .device_components import CabledObjectModel, PathEndpoint
__all__ = ( __all__ = (
'PowerFeed', '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. An electrical circuit delivered from a PowerPanel.
""" """

View File

@ -432,17 +432,17 @@ class Rack(NetBoxModel):
if not available_power_total: if not available_power_total:
return 0 return 0
pf_powerports = PowerPort.objects.filter( powerports = []
_link_peer_type=ContentType.objects.get_for_model(PowerFeed), for powerfeed in powerfeeds:
_link_peer_id__in=powerfeeds.values_list('id', flat=True) powerports.extend([
) peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
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
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): class RackReservation(NetBoxModel):

View File

@ -1,11 +1,11 @@
import logging import logging
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from .choices import LinkStatusChoices from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis 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 from .utils import create_cablepath, rebuild_paths
@ -68,73 +68,55 @@ def clear_virtualchassis_members(instance, **kwargs):
# Cables # Cables
# #
@receiver(trace_paths, sender=Cable)
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs): 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') logger = logging.getLogger('netbox.dcim.cable')
if raw: if raw:
logger.debug(f"Skipping endpoint updates for imported cable {instance}") logger.debug(f"Skipping endpoint updates for imported cable {instance}")
return return
# Cache the Cable on its two termination points # Update cable paths if new terminations have been set
if instance.termination_a.cable != instance: if hasattr(instance, 'a_terminations') or hasattr(instance, 'b_terminations'):
logger.debug(f"Updating termination A for cable {instance}") a_terminations = []
instance.termination_a.cable = instance b_terminations = []
instance.termination_a._link_peer = instance.termination_b for t in instance.terminations.all():
instance.termination_a.save() if t.cable_end == CableEndChoices.SIDE_A:
if instance.termination_b.cable != instance: a_terminations.append(t.termination)
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)
else: 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: 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: 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: else:
rebuild_paths(instance) rebuild_paths([instance])
@receiver(post_delete, sender=Cable) @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): 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') model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
# 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()

View File

@ -1,12 +1,14 @@
import svgwrite import svgwrite
from django.conf import settings
from svgwrite.container import Group, Hyperlink 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 svgwrite.text import Text
from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color from utilities.utils import foreground_color
__all__ = ( __all__ = (
'CableTraceSVG', 'CableTraceSVG',
) )
@ -15,6 +17,95 @@ __all__ = (
OFFSET = 0.5 OFFSET = 0.5
PADDING = 10 PADDING = 10
LINE_HEIGHT = 20 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: class CableTraceSVG:
@ -25,7 +116,7 @@ class CableTraceSVG:
:param width: Width of the generated image (in pixels) :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. :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.origin = origin
self.width = width self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else '' 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 # Center edges on pixels to render sharp borders
self.cursor = OFFSET self.cursor = OFFSET
# Prep elements lists
self.parent_objects = []
self.terminations = []
self.connectors = []
@property @property
def center(self): def center(self):
return self.width / 2 return self.width / 2
@ -78,95 +174,103 @@ class CableTraceSVG:
# Other parent object # Other parent object
return 'e0e0e0' 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 Draw a set of parent objects.
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)
""" """
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 def draw_terminations(self, terminations):
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') """
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 for i, term in enumerate(terminations):
position = ( node = Node(
OFFSET + (self.width - width) / 2, position=(i * width, self.cursor),
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 \ self.connectors.extend((
+ LINE_HEIGHT * len(labels) \ Polyline(points=points, class_='cable-shadow'),
+ PADDING * padding_multiplier Polyline(points=points, style=f'stroke: #{connector.color}'),
box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}') ))
link.add(box)
self.cursor += PADDING * padding_multiplier
# Add text label(s) def draw_fanout(self, node, connector):
for i, label in enumerate(labels): points = (
self.cursor += LINE_HEIGHT connector.end,
text_coords = (self.center, self.cursor - LINE_HEIGHT / 2) (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
text_color = f'#{foreground_color(color, dark="303030")}' node.top_center,
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) )
link.add(text) 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 connector
"""
Return an SVG group containing a line element and text labels representing a Cable.
:param color: Cable (line) color def draw_wirelesslink(self, wirelesslink):
: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):
""" """
Draw a line with labels representing a WirelessLink. Draw a line with labels representing a WirelessLink.
:param url: Hyperlink URL
:param labels: Iterable of text labels
""" """
group = Group(class_='connector') group = Group(class_='connector')
labels = [
f'Wireless link {wirelesslink}',
wirelesslink.get_status_display()
]
if wirelesslink.ssid:
labels.append(wirelesslink.ssid)
# Draw the wireless link # Draw the wireless link
start = (OFFSET + self.center, self.cursor) start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
@ -177,7 +281,7 @@ class CableTraceSVG:
self.cursor += PADDING * 2 self.cursor += PADDING * 2
# Add link # 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) # Add text label(s)
for i, label in enumerate(labels): for i, label in enumerate(labels):
@ -191,7 +295,7 @@ class CableTraceSVG:
return group return group
def _draw_attachment(self): def draw_attachment(self):
""" """
Return an SVG group containing a line element and "Attachment" label. Return an SVG group containing a line element and "Attachment" label.
""" """
@ -216,109 +320,63 @@ class CableTraceSVG:
traced_path = self.origin.trace() traced_path = self.origin.trace()
# Prep elements list # Iterate through each (terms, cable, terms) segment in the path
parent_objects = []
terminations = []
connectors = []
# Iterate through each (term, cable, term) segment in the path
for i, segment in enumerate(traced_path): for i, segment in enumerate(traced_path):
near_end, connector, far_end = segment near_ends, links, far_ends = segment
# Near end parent # Near end parent
if i == 0: if i == 0:
# If this is the first segment, draw the originating termination's parent object # If this is the first segment, draw the originating termination's parent object
parent_object = self._draw_box( self.draw_parent_objects(set(end.parent_object for end in near_ends))
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)
# Near end termination # Near end termination(s)
if near_end is not None: terminations = self.draw_terminations(near_ends)
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)
# Connector (a Cable or WirelessLink) # Connector (a Cable or WirelessLink)
if connector is not None: if links:
link = links[0] # Remove Cable from list
# Cable # Cable
if type(connector) is Cable: if type(link) is Cable:
connector_labels = [
f'Cable {connector}', # Account for fan-ins height
connector.get_status_display() if len(near_ends) > 1:
] self.cursor += FANOUT_HEIGHT
if connector.type:
connector_labels.append(connector.get_type_display()) cable = self.draw_cable(link)
if connector.length and connector.length_unit: self.connectors.append(cable)
connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
cable = self._draw_cable( # Draw fan-ins
color=connector.color or '000000', if len(near_ends) > 1:
url=connector.get_absolute_url(), for term in terminations:
labels=connector_labels self.draw_fanin(term, cable)
)
connectors.append(cable)
# WirelessLink # WirelessLink
elif type(connector) is WirelessLink: elif type(link) is WirelessLink:
connector_labels = [ wirelesslink = self.draw_wirelesslink(link)
f'Wireless link {connector}', self.connectors.append(wirelesslink)
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)
# Far end termination # Far end termination(s)
termination = self._draw_box( if len(far_ends) > 1:
width=self.width * .8, self.cursor += FANOUT_HEIGHT
color=self._get_color(far_end), terminations = self.draw_terminations(far_ends)
url=far_end.get_absolute_url(), for term in terminations:
labels=self._get_labels(far_end), self.draw_fanout(term, cable)
radius=5 else:
) self.draw_terminations(far_ends)
terminations.append(termination)
# Far end parent # Far end parent
parent_object = self._draw_box( parent_objects = set(end.parent_object for end in far_ends)
width=self.width, self.draw_parent_objects(parent_objects)
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)
elif far_end: elif far_ends:
# Attachment # Attachment
attachment = self._draw_attachment() attachment = self.draw_attachment()
connectors.append(attachment) self.connectors.append(attachment)
# ProviderNetwork # ProviderNetwork
parent_object = self._draw_box( self.draw_parent_objects(set(end.parent_object for end in far_ends))
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)
# Determine drawing size # Determine drawing size
self.drawing = svgwrite.Drawing( self.drawing = svgwrite.Drawing(
@ -330,7 +388,7 @@ class CableTraceSVG:
self.drawing.defs.add(self.drawing.style(css_file.read())) self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis) # 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) self.drawing.add(element)
return self.drawing return self.drawing

View File

@ -1,56 +1,109 @@
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from django.utils.safestring import mark_safe
from dcim.models import Cable from dcim.models import Cable
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT from .template_code import CABLE_LENGTH
__all__ = ( __all__ = (
'CableTable', '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 # Cables
# #
class CableTable(NetBoxTable): class CableTable(NetBoxTable):
termination_a_parent = tables.TemplateColumn( a_terminations = CableTerminationsColumn(
template_code=CABLE_TERMINATION_PARENT, cable_end='A',
accessor=Accessor('termination_a'),
orderable=False, 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' verbose_name='Termination A'
) )
termination_b_parent = tables.TemplateColumn( b_terminations = CableTerminationsColumn(
template_code=CABLE_TERMINATION_PARENT, cable_end='B',
accessor=Accessor('termination_b'),
orderable=False, orderable=False,
verbose_name='Side B' verbose_name='Termination B'
) )
rack_b = tables.Column( device_a = CableTerminationsColumn(
accessor=Accessor('termination_b__device__rack'), 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, orderable=False,
linkify=True,
verbose_name='Rack B' verbose_name='Rack B'
) )
termination_b = tables.Column( site_a = CableTerminationsColumn(
accessor=Accessor('termination_b'), cable_end='A',
attr='_site',
orderable=False, orderable=False,
linkify=True, verbose_name='Site A'
verbose_name='Termination B' )
site_b = CableTerminationsColumn(
cable_end='B',
attr='_site',
orderable=False,
verbose_name='Site B'
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
tenant = TenantColumn() tenant = TenantColumn()
@ -66,10 +119,10 @@ class CableTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'color', 'length', 'tags',
'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
'status', 'type',
) )

View File

@ -13,15 +13,17 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
""" """
CABLE_TERMINATION_PARENT = """ # CABLE_TERMINATION_PARENT = """
{% if value.device %} # {% with value.0 as termination %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a> # {% if termination.device %}
{% elif value.circuit %} # <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a> # {% elif termination.circuit %}
{% elif value.power_panel %} # <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a> # {% elif termination.power_panel %}
{% endif %} # <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
""" # {% endif %}
# {% endwith %}
# """
DEVICE_LINK = """ DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}"> <a href="{% url 'dcim:device' pk=record.pk %}">
@ -133,9 +135,9 @@ CONSOLEPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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: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: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.rearport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul> </ul>
</span> </span>
{% else %} {% else %}
@ -165,9 +167,9 @@ CONSOLESERVERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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: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: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.rearport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul> </ul>
</span> </span>
{% else %} {% else %}
@ -197,8 +199,8 @@ POWERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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.powerfeed&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul> </ul>
</span> </span>
{% else %} {% 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-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %} {% 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> <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a> </a>
{% else %} {% else %}
@ -274,10 +276,10 @@ INTERFACE_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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: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: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: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: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=circuits.circuittermination&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul> </ul>
</span> </span>
{% else %} {% else %}
@ -313,12 +315,12 @@ FRONTPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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: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: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: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: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: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: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: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: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=circuits.circuittermination&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul> </ul>
</span> </span>
{% else %} {% else %}
@ -350,12 +352,12 @@ REARPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <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: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: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: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: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: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: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: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: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: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: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=circuits.circuitterminations&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul> </ul>
</span> </span>
{% else %} {% else %}

View File

@ -45,7 +45,7 @@ class Mixins:
device=peer_device, device=peer_device,
name='Peer Termination' 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() cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}') 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.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
segment1 = response.data[0] 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[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): class RegionTest(APIViewTestCases.APIViewTestCase):
@ -1884,33 +1884,33 @@ class CableTest(APIViewTestCases.APIViewTestCase):
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
cables = ( cables = (
Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'), Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'), Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'), Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
) )
for cable in cables: for cable in cables:
cable.save() cable.save()
cls.create_data = [ cls.create_data = [
{ {
'termination_a_type': 'dcim.interface', 'a_terminations_type': 'dcim.interface',
'termination_a_id': interfaces[4].pk, 'a_terminations': [interfaces[4].pk],
'termination_b_type': 'dcim.interface', 'b_terminations_type': 'dcim.interface',
'termination_b_id': interfaces[14].pk, 'b_terminations': [interfaces[14].pk],
'label': 'Cable 4', 'label': 'Cable 4',
}, },
{ {
'termination_a_type': 'dcim.interface', 'a_terminations_type': 'dcim.interface',
'termination_a_id': interfaces[5].pk, 'a_terminations': [interfaces[5].pk],
'termination_b_type': 'dcim.interface', 'b_terminations_type': 'dcim.interface',
'termination_b_id': interfaces[15].pk, 'b_terminations': [interfaces[15].pk],
'label': 'Cable 5', 'label': 'Cable 5',
}, },
{ {
'termination_a_type': 'dcim.interface', 'a_terminations_type': 'dcim.interface',
'termination_a_id': interfaces[6].pk, 'a_terminations': [interfaces[6].pk],
'termination_b_type': 'dcim.interface', 'b_terminations_type': 'dcim.interface',
'termination_b_id': interfaces[16].pk, 'b_terminations': [interfaces[16].pk],
'label': 'Cable 6', 'label': 'Cable 6',
}, },
] ]
@ -1936,7 +1936,7 @@ class ConnectedDeviceTest(APITestCase):
self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected 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() cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @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) ConsolePort.objects.bulk_create(console_ports)
# Cables # Cables
Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save() Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save()
Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save() Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -2097,8 +2097,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsoleServerPort.objects.bulk_create(console_server_ports) ConsoleServerPort.objects.bulk_create(console_server_ports)
# Cables # Cables
Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save() Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save()
Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save() Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -2244,8 +2244,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort.objects.bulk_create(power_ports) PowerPort.objects.bulk_create(power_ports)
# Cables # Cables
Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save() Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save()
Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save() Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -2399,8 +2399,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet.objects.bulk_create(power_outlets) PowerOutlet.objects.bulk_create(power_outlets)
# Cables # Cables
Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save() Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save()
Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save() Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -2656,8 +2656,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
# Cables # Cables
Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save() Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
# Third pair is not connected # Third pair is not connected
def test_name(self): def test_name(self):
@ -2932,8 +2932,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
FrontPort.objects.bulk_create(front_ports) FrontPort.objects.bulk_create(front_ports)
# Cables # Cables
Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save() Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save() Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -3078,8 +3078,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports) RearPort.objects.bulk_create(rear_ports)
# Cables # Cables
Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save() Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save()
Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save() Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save()
# Third port is not connected # Third port is not connected
def test_name(self): def test_name(self):
@ -3663,6 +3663,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Site.objects.bulk_create(sites) 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 = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
@ -3670,24 +3685,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Tenant.objects.bulk_create(tenants) 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') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-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') device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), 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], position=2), 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], position=1), 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], position=2), 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], position=1), 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], position=2), 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) 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') console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables # 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(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(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(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(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(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(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(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(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(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(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(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(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
def test_label(self): def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']} params = {'label': ['Cable 1', 'Cable 2']}
@ -3759,6 +3767,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]} params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) 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): def test_site(self):
site = Site.objects.all()[:2] site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]} 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self): 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 = { params = {
'termination_a_type': 'dcim.interface', 'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids), 'termination_a_id': list(interface_ids),
@ -3924,8 +3942,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort(device=device, name='Power Port 2'), PowerPort(device=device, name='Power Port 2'),
] ]
PowerPort.objects.bulk_create(power_ports) PowerPort.objects.bulk_create(power_ports)
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save() Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save() Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save()
def test_name(self): def test_name(self):
params = {'name': ['Power Feed 1', 'Power Feed 2']} 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.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1') 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.cable.save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') 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. When a new Cable is created, it must be cached on either termination point.
""" """
interface1 = Interface.objects.get(pk=self.interface1.pk) self.interface1.refresh_from_db()
interface2 = Interface.objects.get(pk=self.interface2.pk) self.interface2.refresh_from_db()
self.assertEqual(self.cable.termination_a, interface1) self.assertEqual(self.interface1.cable, self.cable)
self.assertEqual(interface1._link_peer, interface2) self.assertEqual(self.interface2.cable, self.cable)
self.assertEqual(self.cable.termination_b, interface2) self.assertEqual(self.interface1.cable_end, 'A')
self.assertEqual(interface2._link_peer, interface1) 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): def test_cable_deletion(self):
""" """
@ -510,50 +512,33 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None') self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable) self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._link_peer) self.assertListEqual(interface1.link_peers, [])
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable) 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(a_terminations=[self.interface1], b_terminations=[self.power_port1])
cable = Cable.objects.filter(pk=self.cable.pk).first() with self.assertRaises(ValidationError):
self.assertIsNone(cable) 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): 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 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 # An interface cannot be connected to a power port, for example
cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) cable = Cable(a_terminations=[self.interface1], b_terminations=[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)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() 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 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): with self.assertRaises(ValidationError):
cable.clean() 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): def test_cable_cannot_terminate_to_a_virtual_interface(self):
""" """
A cable cannot terminate to a virtual interface A cable cannot terminate to a virtual interface
""" """
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) 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): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
@ -608,6 +564,6 @@ class CableTestCase(TestCase):
A cable cannot terminate to a wireless interface A cable cannot terminate to a wireless interface
""" """
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) 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): with self.assertRaises(ValidationError):
cable.clean() cable.clean()

View File

@ -1961,7 +1961,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleport.device, device=consoleport.device,
name='Console Server Port 1' 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})) response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2017,7 +2017,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleserverport.device, device=consoleserverport.device,
name='Console Port 1' 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})) response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2079,7 +2079,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=powerport.device, device=powerport.device,
name='Power Outlet 1' 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})) response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2144,7 +2144,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
def test_trace(self): def test_trace(self):
poweroutlet = PowerOutlet.objects.first() poweroutlet = PowerOutlet.objects.first()
powerport = PowerPort.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})) response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2268,7 +2268,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2] 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})) response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2339,7 +2339,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=frontport.device, device=frontport.device,
name='Interface 1' 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})) response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2397,7 +2397,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=rearport.device, device=rearport.device,
name='Interface 1' 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})) response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@ -2630,19 +2630,18 @@ class CableTestCase(
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save() Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() Cable(a_terminations=[interfaces[1]], b_terminations=[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[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save()
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
interface_ct = ContentType.objects.get_for_model(Interface) interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = { cls.form_data = {
# TODO: Revisit this limitation
# Changing terminations not supported when editing an existing Cable # Changing terminations not supported when editing an existing Cable
'termination_a_type': interface_ct.pk, 'a_terminations': interfaces[0].pk,
'termination_a_id': interfaces[0].pk, 'b_terminations': interfaces[3].pk,
'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6, 'type': CableTypeChoices.TYPE_CAT6,
'status': LinkStatusChoices.STATUS_PLANNED, 'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label', 'label': 'Label',
@ -2864,7 +2863,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
device=device, device=device,
name='Power Port 1' 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})) response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
self.assertHttpStatus(response, 200) 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>/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>/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: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'), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports # 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>/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>/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: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'), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports # 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>/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>/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: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'), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets # 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>/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>/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: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'), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces # Interfaces
@ -358,7 +354,6 @@ urlpatterns = [
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), 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>/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: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'), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports # 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>/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>/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: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'), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports # 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>/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>/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: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'), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Module bays # Module bays
@ -447,6 +440,7 @@ urlpatterns = [
# Cables # Cables
path('cables/', views.CableListView.as_view(), name='cable_list'), 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/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), 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>/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>/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: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.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
@ -29,27 +31,29 @@ def path_node_to_object(repr):
return ct.model_class().objects.get(pk=object_id) 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 from dcim.models import CablePath
cp = CablePath.from_origin(node) cp = CablePath.from_origin(terminations)
if cp: if cp:
cp.save() 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 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(): with transaction.atomic():
for cp in cable_paths: for cp in cable_paths:
cp.delete() cp.delete()
if cp.origin: create_cablepath(cp.origins)
create_cablepath(cp.origin)

View File

@ -12,7 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import * 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): class DeviceComponentsView(generic.ObjectChildrenView):
queryset = Device.objects.all() queryset = Device.objects.all()
@ -1717,7 +1729,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
'_path__destination' '_path'
).exclude( ).exclude(
type__in=NONCONNECTABLE_IFACE_TYPES type__in=NONCONNECTABLE_IFACE_TYPES
) )
@ -2744,7 +2756,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
# #
class CableListView(generic.ObjectListView): 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 = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm filterset_form = forms.CableFilterForm
table = tables.CableTable table = tables.CableTable
@ -2777,7 +2792,7 @@ class PathTraceView(generic.ObjectView):
# Otherwise, find all CablePaths which traverse the specified object # Otherwise, find all CablePaths which traverse the specified object
else: 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) # Check for specification of a particular path (when tracing pass-through ports)
try: try:
path_id = int(request.GET.get('cablepath_id')) 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) total_length, is_definitive = path.get_total_length() if path else (None, False)
# Determine the path to the SVG trace image # Determine the path to the SVG trace image
api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace" api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace"
svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg" svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg"
return { return {
'path': path, 'path': path,
@ -2810,77 +2825,38 @@ class PathTraceView(generic.ObjectView):
} }
class CableCreateView(generic.ObjectEditView): class CableEditView(generic.ObjectEditView):
queryset = Cable.objects.all() queryset = Cable.objects.all()
template_name = 'dcim/cable_connect.html' template_name = 'dcim/cable_edit.html'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Set the form class based on the type of component being connected # If creating a new Cable, initialize the form class using URL query params
self.form = { if 'pk' not in kwargs:
'console-port': forms.ConnectCableToConsolePortForm, self.form = forms.get_cable_form(
'console-server-port': forms.ConnectCableToConsoleServerPortForm, a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
'power-port': forms.ConnectCableToPowerPortForm, b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
'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')]
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_object(self, **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): if obj.pk:
termination_a_type = url_kwargs.get('termination_a_type') # TODO: Optimize this logic
termination_a_id = url_kwargs.get('termination_a_id') termination_a = obj.terminations.filter(cable_end='A').first()
termination_b_type_name = url_kwargs.get('termination_b_type') a_type = termination_a.termination._meta.model if termination_a else None
self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) termination_b = obj.terminations.filter(cable_end='B').first()
b_type = termination_b.termination._meta.model if termination_a else None
# Initialize Cable termination attributes self.form = forms.get_cable_form(a_type, b_type)
obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
obj.termination_b_type = self.termination_b_type
return obj 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): class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all() queryset = Cable.objects.all()
@ -2893,14 +2869,14 @@ class CableBulkImportView(generic.BulkImportView):
class CableBulkEditView(generic.BulkEditView): class CableBulkEditView(generic.BulkEditView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') queryset = Cable.objects.prefetch_related('terminations')
filterset = filtersets.CableFilterSet filterset = filtersets.CableFilterSet
table = tables.CableTable table = tables.CableTable
form = forms.CableBulkEditForm form = forms.CableBulkEditForm
class CableBulkDeleteView(generic.BulkDeleteView): class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') queryset = Cable.objects.prefetch_related('terminations')
filterset = filtersets.CableFilterSet filterset = filtersets.CableFilterSet
table = tables.CableTable table = tables.CableTable

View File

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

View File

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

Binary file not shown.

View File

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

View File

@ -44,16 +44,15 @@
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">Marked as connected</span> <span class="text-muted">Marked as connected</span>
{% elif termination.cable %} {% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> <a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> to
{% with peer=termination.get_link_peer %} {% for peer in termination.link_peers %}
to
{% if peer.device %} {% if peer.device %}
{{ peer.device|linkify }}<br/> {{ peer.device|linkify }}<br/>
{% elif peer.circuit %} {% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/> {{ peer.circuit|linkify }}<br/>
{% endif %} {% endif %}
{{ peer|linkify }} {{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endwith %} {% endfor %}
<div class="mt-1"> <div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace"> <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 <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 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
</button> </button>
<ul class="dropdown-menu"> <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 '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 '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 '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 '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 '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 '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=circuits.circuittermination&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

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

View File

@ -111,28 +111,13 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<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>
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>
</li> </li>
<li> <li>
<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>
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>
</li> </li>
<li> <li>
<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>
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>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -113,28 +113,13 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<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>
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>
</li> </li>
<li> <li>
<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>
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>
</li> </li>
<li> <li>
<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>
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>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -105,22 +105,22 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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>
<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>
<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>
<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> </li>
</ul> </ul>
</div> </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 %} {% load helpers %}
<table class="table table-hover panel-body attr-table"> <table class="table table-hover panel-body attr-table">
{% if termination.device %} {% if terminations.0.device %}
{# Device component #} {# Device component #}
<tr> <tr>
<td>Device</td> <td>Site</td>
<td>{{ termination.device|linkify }}</td> <td>{{ terminations.0.device.site|linkify }}</td>
</tr> </tr>
<tr> <tr>
<td>Site</td> <td>Rack</td>
<td>{{ termination.device.site|linkify }}</td> <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
</tr> </tr>
{% if termination.device.rack %} <tr>
<tr> <td>Device</td>
<td>Rack</td> <td>{{ terminations.0.device|linkify }}</td>
<td>{{ termination.device.rack|linkify }}</td> </tr>
</tr> <tr>
{% endif %} <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<tr> <td>
<td>Type</td> {% for term in terminations %}
<td>{{ termination|meta:"verbose_name"|capfirst }}</td> {{ term|linkify }}{% if not forloop.last %},{% endif %}
</tr> {% endfor %}
<tr> </td>
<td>Component</td> </tr>
<td>{{ termination|linkify }}</td> {% elif terminations.0.power_panel %}
</tr> {# Power feed #}
{% else %} <tr>
{# Circuit termination #} <td>Site</td>
<tr> <td>{{ terminations.0.power_panel.site|linkify }}</td>
<td>Provider</td> </tr>
<td>{{ termination.circuit.provider|linkify }}</td> <tr>
</tr> <td>Power Panel</td>
<tr> <td>{{ terminations.0.power_panel|linkify }}</td>
<td>Circuit</td> </tr>
<td>{{ termination.circuit|linkify }}</td> <tr>
</tr> <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<tr> <td>
<td>Termination</td> {% for term in terminations %}
<td>{{ termination }}</td> {{ term|linkify }}{% if not forloop.last %},{% endif %}
</tr> {% endfor %}
{% endif %} </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> </table>

View File

@ -263,24 +263,16 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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 }}"> <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>
Interface
</a>
</li> </li>
<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 }}"> <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>
Front Port
</a>
</li> </li>
<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 }}"> <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>
Rear Port
</a>
</li> </li>
<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 }}"> <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>
Circuit Termination
</a>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -158,8 +158,7 @@
{% if not object.mark_connected and not object.cable %} {% if not object.mark_connected and not object.cable %}
<div class="card-footer"> <div class="card-footer">
{% if perms.dcim.add_cable %} {% 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 }}" <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">
class="btn btn-primary btn-sm float-end">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a> </a>
{% endif %} {% endif %}

View File

@ -111,7 +111,7 @@
<div class="text-muted"> <div class="text-muted">
Not Connected Not Connected
{% if perms.dcim.add_cable %} {% 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 <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a> </a>
{% endif %} {% endif %}

View File

@ -117,10 +117,10 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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> </li>
</ul> </ul>
</span> </span>

View File

@ -101,16 +101,16 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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>
<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> </li>
</ul> </ul>
</span> </span>

View File

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