diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 456afd765..ac499f806 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,28 @@ * Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). +* Several fields on the cable API serializers have been altered to support multiple-object cable terminations: + +| Old Name | Old Type | New Name | New Type | +|----------------------|----------|-----------------------|----------| +| `termination_a_type` | string | `a_terminations_type` | string | +| `termination_b_type` | string | `b_terminations_type` | string | +| `termination_a_id` | integer | _Removed_ | - | +| `termination_b_id` | integer | _Removed_ | - | +| `termination_a` | object | `a_terminations` | list | +| `termination_b` | object | `b_terminations` | list | + +* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed: + +| Old Name | Old Type | New Name | New Type | +|--------------------------------|----------|---------------------------------|----------| +| `link_peer` | object | `link_peers` | list | +| `link_peer_type` | string | `link_peers_type` | string | +| `connected_endpoint` | object | `connected_endpoints` | list | +| `connected_endpoint_type` | string | `connected_endpoints_type` | string | +| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean | + +* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.) ### New Features @@ -19,6 +41,8 @@ #### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) +#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -55,23 +79,83 @@ ### REST API Changes * Added the following endpoints: + * `/api/dcim/cable-terminations/` * `/api/ipam/l2vpns/` * `/api/ipam/l2vpn-terminations/` * circuits.Circuit * Added optional `termination_date` field * circuits.CircuitTermination - * Added 'custom_fields' and 'tags' fields + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` + * Added `custom_fields` and `tags` fields +* dcim.Cable + * `termination_a_type` has been renamed to `a_terminations_type` + * `termination_b_type` has been renamed to `b_terminations_type` + * `termination_a` renamed to `a_terminations` and now returns a list of objects + * `termination_b` renamed to `b_terminations` and now returns a list of objects + * `termination_a_id` has been removed + * `termination_b_id` has been removed +* dcim.ConsolePort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.ConsoleServerPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * dcim.Device * The `position` field has been changed from an integer to a decimal * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal +* dcim.FrontPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * dcim.Interface + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * Added the optional `poe_mode` and `poe_type` fields * Added the `l2vpn_termination` read-only field * dcim.Location * Added required `status` field (default value: `active`) +* dcim.PowerOutlet + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.PowerFeed + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.PowerPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit +* dcim.RearPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * extras.ConfigContext * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations * extras.CustomField diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 844cfce89..6b09cd531 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,11 +3,11 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer -from dcim.api.serializers import LinkTerminationSerializer +from dcim.api.serializers import CabledObjectSerializer from ipam.models import ASN from ipam.api.nested_serializers import NestedASNSerializer from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -98,17 +98,16 @@ class CircuitSerializer(NetBoxModelSerializer): ] -class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer): +class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True) - cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', 'tags', 'custom_fields', 'created', 'last_updated', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3573c05e3..f5f3f0fab 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet): class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'provider_network', 'cable' + 'circuit', 'site', 'provider_network', 'cable__terminations' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filtersets.CircuitTerminationFilterSet diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index a74ff5c5a..8005c0afe 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.filtersets import CableTerminationFilterSet +from dcim.filtersets import CabledObjectFilterSet from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet @@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ).distinct() -class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet): +class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSe class Meta: model = CircuitTermination - fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description'] + fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 094b78d07..e96fe98a5 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,5 @@ from circuits import filtersets, models +from dcim.graphql.mixins import CabledObjectMixin from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType @@ -11,7 +12,7 @@ __all__ = ( ) -class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): class Meta: model = models.CircuitTermination diff --git a/netbox/circuits/migrations/0036_circuit_termination_date.py b/netbox/circuits/migrations/0036_circuit_termination_date.py deleted file mode 100644 index 0a8adfbe6..000000000 --- a/netbox/circuits/migrations/0036_circuit_termination_date.py +++ /dev/null @@ -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), - ), - ] diff --git a/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py similarity index 75% rename from netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py rename to netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py index c87bc4219..c686bf042 100644 --- a/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -6,11 +6,15 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('extras', '0076_configcontext_locations'), - ('circuits', '0036_circuit_termination_date'), + ('circuits', '0035_provider_asns'), ] operations = [ + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), migrations.AddField( model_name='circuittermination', name='custom_field_data', diff --git a/netbox/circuits/migrations/0037_new_cabling_models.py b/netbox/circuits/migrations/0037_new_cabling_models.py new file mode 100644 index 000000000..ee08147f3 --- /dev/null +++ b/netbox/circuits/migrations/0037_new_cabling_models.py @@ -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), + ), + ] diff --git a/netbox/circuits/migrations/0038_cabling_cleanup.py b/netbox/circuits/migrations/0038_cabling_cleanup.py new file mode 100644 index 000000000..0672057e3 --- /dev/null +++ b/netbox/circuits/migrations/0038_cabling_cleanup.py @@ -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', + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index cf6ffc503..d82878cde 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from circuits.choices import * -from dcim.models import LinkTermination +from dcim.models import CabledObjectModel from netbox.models import ( ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, ) @@ -149,7 +149,7 @@ class CircuitTermination( TagsMixin, WebhooksMixin, ChangeLoggedModel, - LinkTermination + CabledObjectModel ): circuit = models.ForeignKey( to='circuits.Circuit', diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 6ec9cc6c3..70f2abb41 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs): if not raw: peer_termination = instance.get_peer_termination() if peer_termination: - rebuild_paths(peer_termination) + rebuild_paths([peer_termination]) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 28e0a3fe3..abcfa8a00 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -360,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): )) CircuitTermination.objects.bulk_create(circuit_terminations) - Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save() + Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save() def test_term_side(self): params = {'term_side': 'A'} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index f60275ff3..fa6146b93 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -246,7 +246,7 @@ class CircuitTerminationTestCase( device=device, name='Interface 1' ) - Cable(termination_a=circuittermination, termination_b=interface).save() + Cable(a_terminations=[circuittermination], b_terminations=[interface]).save() response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk})) self.assertHttpStatus(response, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index f3ee64cf0..5b15b29ac 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from dcim.views import CableCreateView, PathTraceView +from dcim.views import PathTraceView from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * @@ -60,7 +60,6 @@ urlpatterns = [ path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4cb988c42..08edd2820 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -28,58 +28,68 @@ from wireless.models import WirelessLAN from .nested_serializers import * -class LinkTerminationSerializer(serializers.ModelSerializer): - link_peer_type = serializers.SerializerMethodField(read_only=True) - link_peer = serializers.SerializerMethodField(read_only=True) +class CabledObjectSerializer(serializers.ModelSerializer): + cable = NestedCableSerializer(read_only=True) + cable_end = serializers.CharField(read_only=True) + link_peers_type = serializers.SerializerMethodField(read_only=True) + link_peers = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True) - def get_link_peer_type(self, obj): - if obj._link_peer is not None: - return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}' + def get_link_peers_type(self, obj): + """ + Return the type of the peer link terminations, or None. + """ + if not obj.cable: + return None + + if obj.link_peers: + return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' + return None - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_link_peer(self, obj): + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_link_peers(self, obj): """ Return the appropriate serializer for the link termination model. """ - if obj._link_peer is not None: - serializer = get_serializer_for_model(obj._link_peer, prefix='Nested') - context = {'request': self.context['request']} - return serializer(obj._link_peer, context=context).data - return None + if not obj.link_peers: + return [] + + # Return serialized peer termination objects + serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.link_peers, context=context, many=True).data @swagger_serializer_method(serializer_or_field=serializers.BooleanField) def get__occupied(self, obj): return obj._occupied -class ConnectedEndpointSerializer(serializers.ModelSerializer): - connected_endpoint_type = serializers.SerializerMethodField(read_only=True) - connected_endpoint = serializers.SerializerMethodField(read_only=True) - connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) +class ConnectedEndpointsSerializer(serializers.ModelSerializer): + """ + Legacy serializer for pre-v3.3 connections + """ + connected_endpoints_type = serializers.SerializerMethodField(read_only=True) + connected_endpoints = serializers.SerializerMethodField(read_only=True) + connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) - def get_connected_endpoint_type(self, obj): - if obj._path is not None and obj._path.destination is not None: - return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}' - return None + def get_connected_endpoints_type(self, obj): + if endpoints := obj.connected_endpoints: + return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_connected_endpoint(self, obj): + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_connected_endpoints(self, obj): """ Return the appropriate serializer for the type of connected object. """ - if obj._path is not None and obj._path.destination is not None: - serializer = get_serializer_for_model(obj._path.destination, prefix='Nested') + if endpoints := obj.connected_endpoints: + serializer = get_serializer_for_model(endpoints[0], prefix='Nested') context = {'request': self.context['request']} - return serializer(obj._path.destination, context=context).data - return None + return serializer(endpoints, many=True, context=context).data @swagger_serializer_method(serializer_or_field=serializers.BooleanField) - def get_connected_endpoint_reachable(self, obj): - if obj._path is not None: - return obj._path.is_active - return None + def get_connected_endpoints_reachable(self, obj): + return obj._path and obj._path.is_complete and obj._path.is_active # @@ -684,7 +694,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -701,18 +711,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ allow_null=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = ConsoleServerPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -729,18 +739,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co allow_null=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = ConsolePort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -761,21 +771,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co allow_blank=True, required=False ) - cable = NestedCableSerializer( - read_only=True - ) class Meta: model = PowerOutlet fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -787,19 +794,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn allow_blank=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = PowerPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -825,7 +831,6 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn ) vrf = NestedVRFSerializer(required=False, allow_null=True) l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) - cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( queryset=WirelessLAN.objects.all(), @@ -842,9 +847,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', - 'vrf', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', + 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', + 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -861,7 +867,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn return super().validate(data) -class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): +class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -869,13 +875,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -891,7 +896,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): +class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -900,14 +905,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() - cable = NestedCableSerializer(read_only=True) class Meta: model = FrontPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -990,14 +994,10 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer): class CableSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - termination_a_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination_b_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination_a = serializers.SerializerMethodField(read_only=True) - termination_b = serializers.SerializerMethodField(read_only=True) + a_terminations_type = serializers.SerializerMethodField(read_only=True) + b_terminations_type = serializers.SerializerMethodField(read_only=True) + a_terminations = serializers.SerializerMethodField(read_only=True) + b_terminations = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=LinkStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) @@ -1005,33 +1005,46 @@ class CableSerializer(NetBoxModelSerializer): class Meta: model = Cable fields = [ - 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', - 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'type', 'a_terminations_type', 'a_terminations', 'b_terminations_type', + 'b_terminations', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields', + 'created', 'last_updated', ] - def _get_termination(self, obj, side): - """ - Serialize a nested representation of a termination. - """ - if side.lower() not in ['a', 'b']: - raise ValueError("Termination side must be either A or B.") - termination = getattr(obj, 'termination_{}'.format(side.lower())) - if termination is None: - return None - serializer = get_serializer_for_model(termination, prefix='Nested') + def _get_terminations_type(self, obj, side): + assert side in CableEndChoices.values() + terms = getattr(obj, f'get_{side.lower()}_terminations')() + if terms: + ct = ContentType.objects.get_for_model(terms[0]) + return f"{ct.app_label}.{ct.model}" + + def _get_terminations(self, obj, side): + assert side in CableEndChoices.values() + terms = getattr(obj, f'get_{side.lower()}_terminations')() + if not terms: + return [] + + termination_type = ContentType.objects.get_for_model(terms[0]) + serializer = get_serializer_for_model(termination_type.model_class(), prefix='Nested') context = {'request': self.context['request']} - data = serializer(termination, context=context).data + data = serializer(terms, context=context, many=True).data return data - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_termination_a(self, obj): - return self._get_termination(obj, 'a') + @swagger_serializer_method(serializer_or_field=serializers.CharField) + def get_a_terminations_type(self, obj): + return self._get_terminations_type(obj, CableEndChoices.SIDE_A) + + @swagger_serializer_method(serializer_or_field=serializers.CharField) + def get_b_terminations_type(self, obj): + return self._get_terminations_type(obj, CableEndChoices.SIDE_B) @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_termination_b(self, obj): - return self._get_termination(obj, 'b') + def get_a_terminations(self, obj): + return self._get_terminations(obj, CableEndChoices.SIDE_A) + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_b_terminations(self, obj): + return self._get_terminations(obj, CableEndChoices.SIDE_B) class TracedCableSerializer(serializers.ModelSerializer): @@ -1047,46 +1060,40 @@ class TracedCableSerializer(serializers.ModelSerializer): ] +class CableTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') + termination_type = ContentTypeField( + queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ) + termination = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CableTermination + fields = [ + 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination' + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_termination(self, obj): + serializer = get_serializer_for_model(obj.termination, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.termination, context=context).data + + class CablePathSerializer(serializers.ModelSerializer): - origin_type = ContentTypeField(read_only=True) - origin = serializers.SerializerMethodField(read_only=True) - destination_type = ContentTypeField(read_only=True) - destination = serializers.SerializerMethodField(read_only=True) path = serializers.SerializerMethodField(read_only=True) class Meta: model = CablePath - fields = [ - 'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split', - ] - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_origin(self, obj): - """ - Return the appropriate serializer for the origin. - """ - serializer = get_serializer_for_model(obj.origin, prefix='Nested') - context = {'request': self.context['request']} - return serializer(obj.origin, context=context).data - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_destination(self, obj): - """ - Return the appropriate serializer for the destination, if any. - """ - if obj.destination_id is not None: - serializer = get_serializer_for_model(obj.destination, prefix='Nested') - context = {'request': self.context['request']} - return serializer(obj.destination, context=context).data - return None + fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] @swagger_serializer_method(serializer_or_field=serializers.ListField) def get_path(self, obj): ret = [] - for node in obj.get_path(): - serializer = get_serializer_for_model(node, prefix='Nested') + for nodes in obj.path_objects: + serializer = get_serializer_for_model(nodes[0], prefix='Nested') context = {'request': self.context['request']} - ret.append(serializer(node, context=context).data) + ret.append(serializer(nodes, context=context, many=True).data) return ret @@ -1129,7 +1136,7 @@ class PowerPanelSerializer(NetBoxModelSerializer): ] -class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -1153,13 +1160,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn choices=PowerFeedPhaseChoices, default=PowerFeedPhaseChoices.PHASE_SINGLE ) - cable = NestedCableSerializer(read_only=True) class Meta: model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index f67d241d5..e73678f71 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet) # Cables router.register('cables', views.CableViewSet) +router.register('cable-terminations', views.CableTerminationViewSet) # Virtual chassis router.register('virtual-chassis', views.VirtualChassisViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0aa21eee4..cff337fcf 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -13,7 +13,9 @@ from rest_framework.viewsets import ViewSet from circuits.models import Circuit from dcim import filtersets +from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * +from dcim.svg import CableTraceSVG from extras.api.views import ConfigContextQuerySetMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -51,37 +53,30 @@ class PathEndpointMixin(object): # Initialize the path array path = [] + # Render SVG image if requested if request.GET.get('render', None) == 'svg': - # Render SVG try: - width = min(int(request.GET.get('width')), 1600) + width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH)) except (ValueError, TypeError): - width = None - drawing = obj.get_trace_svg( - base_url=request.build_absolute_uri('/'), - width=width - ) - return HttpResponse(drawing.tostring(), content_type='image/svg+xml') + width = CABLE_TRACE_SVG_DEFAULT_WIDTH + drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width) + return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml') + # Serialize path objects, iterating over each three-tuple in the path for near_end, cable, far_end in obj.trace(): - if near_end is None: - # Split paths + if near_end is not None: + serializer_a = get_serializer_for_model(near_end[0], prefix='Nested') + near_end = serializer_a(near_end, many=True, context={'request': request}).data + else: + # Path is split; stop here break - - # Serialize each object - serializer_a = get_serializer_for_model(near_end, prefix='Nested') - x = serializer_a(near_end, context={'request': request}).data if cable is not None: - y = serializers.TracedCableSerializer(cable, context={'request': request}).data - else: - y = None + cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data if far_end is not None: - serializer_b = get_serializer_for_model(far_end, prefix='Nested') - z = serializer_b(far_end, context={'request': request}).data - else: - z = None + serializer_b = get_serializer_for_model(far_end[0], prefix='Nested') + far_end = serializer_b(far_end, many=True, context={'request': request}).data - path.append((x, y, z)) + path.append((near_end, cable, far_end)) return Response(path) @@ -94,7 +89,7 @@ class PassThroughPortMixin(object): Return all CablePaths which traverse a given pass-through port. """ obj = get_object_or_404(self.queryset, pk=pk) - cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination') + cablepaths = CablePath.objects.filter(_nodes__contains=obj) serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True) return Response(serializer.data) @@ -557,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet): class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsolePort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet @@ -566,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -575,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet @@ -584,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerOutlet.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet @@ -593,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', - 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -603,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = FrontPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags' ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet @@ -612,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = RearPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + 'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags' ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet @@ -657,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet): # class CableViewSet(NetBoxModelViewSet): - metadata_class = ContentTypeMetadata - queryset = Cable.objects.prefetch_related( - 'termination_a', 'termination_b' - ) + queryset = Cable.objects.prefetch_related('terminations__termination') serializer_class = serializers.CableSerializer filterset_class = filtersets.CableFilterSet +class CableTerminationViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = CableTermination.objects.prefetch_related('cable', 'termination') + serializer_class = serializers.CableTerminationSerializer + filterset_class = filtersets.CableTerminationFilterSet + + # # Virtual chassis # @@ -698,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet): class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' + 'power_panel', 'rack', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet @@ -758,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet): device=peer_device, name=peer_interface_name ) - endpoint = peer_interface.connected_endpoint + endpoints = peer_interface.connected_endpoints # If an Interface, return the parent device - if type(endpoint) is Interface: + if endpoints and type(endpoints[0]) is Interface: device = get_object_or_404( Device.objects.restrict(request.user, 'view'), - pk=endpoint.device_id + pk=endpoints[0].device_id ) return Response(serializers.DeviceSerializer(device, context={'request': request}).data) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 94c8b255f..1a66312da 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index b813b862d..9e41ed113 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -85,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q( # Cabling and connections # +CABLE_TRACE_SVG_DEFAULT_WIDTH = 400 + # Cable endpoint types CABLE_TERMINATION_MODELS = Q( Q(app_label='circuits', model__in=( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ee95fd75d..f55b3301e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -21,6 +21,7 @@ from .models import * __all__ = ( 'CableFilterSet', + 'CabledObjectFilterSet', 'CableTerminationFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', @@ -1117,7 +1118,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): ) -class CableTerminationFilterSet(django_filters.FilterSet): +class CabledObjectFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -1140,7 +1141,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): class ConsolePortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1150,13 +1151,13 @@ class ConsolePortFilterSet( class Meta: model = ConsolePort - fields = ['id', 'name', 'label', 'description'] + fields = ['id', 'name', 'label', 'description', 'cable_end'] class ConsoleServerPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1166,13 +1167,13 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'label', 'description'] + fields = ['id', 'name', 'label', 'description', 'cable_end'] class PowerPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1182,13 +1183,13 @@ class PowerPortFilterSet( class Meta: model = PowerPort - fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] + fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end'] class PowerOutletFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1202,13 +1203,13 @@ class PowerOutletFilterSet( class Meta: model = PowerOutlet - fields = ['id', 'name', 'label', 'feed_leg', 'description'] + fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] class InterfaceFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis @@ -1288,7 +1289,7 @@ class InterfaceFilterSet( model = Interface fields = [ 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', ] def filter_device(self, queryset, name, value): @@ -1342,7 +1343,7 @@ class InterfaceFilterSet( class FrontPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet + CabledObjectFilterSet ): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -1351,13 +1352,13 @@ class FrontPortFilterSet( class Meta: model = FrontPort - fields = ['id', 'name', 'label', 'type', 'color', 'description'] + fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end'] class RearPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet + CabledObjectFilterSet ): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -1366,7 +1367,7 @@ class RearPortFilterSet( class Meta: model = RearPort - fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] + fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end'] class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): @@ -1514,10 +1515,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): - termination_a_type = ContentTypeFilter() - termination_a_id = MultiValueNumberFilter() - termination_b_type = ContentTypeFilter() - termination_b_id = MultiValueNumberFilter() + termination_a_type = ContentTypeFilter( + field_name='terminations__termination_type' + ) + termination_a_id = MultiValueNumberFilter( + field_name='terminations__termination_id' + ) + termination_b_type = ContentTypeFilter( + field_name='terminations__termination_type' + ) + termination_b_id = MultiValueNumberFilter( + field_name='terminations__termination_id' + ) type = django_filters.MultipleChoiceFilter( choices=CableTypeChoices ) @@ -1528,44 +1537,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): choices=ColorChoices ) device_id = MultiValueNumberFilter( - method='filter_device' + method='filter_by_termination' ) device = MultiValueCharFilter( - method='filter_device', + method='filter_by_termination', field_name='device__name' ) rack_id = MultiValueNumberFilter( - method='filter_device', - field_name='device__rack_id' + method='filter_by_termination', + field_name='rack_id' ) rack = MultiValueCharFilter( - method='filter_device', - field_name='device__rack__name' + method='filter_by_termination', + field_name='rack__name' + ) + location_id = MultiValueNumberFilter( + method='filter_by_termination', + field_name='location_id' + ) + location = MultiValueCharFilter( + method='filter_by_termination', + field_name='location__name' ) site_id = MultiValueNumberFilter( - method='filter_device', - field_name='device__site_id' + method='filter_by_termination', + field_name='site_id' ) site = MultiValueCharFilter( - method='filter_device', - field_name='device__site__slug' + method='filter_by_termination', + field_name='site__slug' ) class Meta: model = Cable - fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id'] + fields = ['id', 'label', 'length', 'length_unit'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(label__icontains=value) - def filter_device(self, queryset, name, value): - queryset = queryset.filter( - Q(**{'_termination_a_{}__in'.format(name): value}) | - Q(**{'_termination_b_{}__in'.format(name): value}) - ) - return queryset + def filter_by_termination(self, queryset, name, value): + # Filter by a related object cached on CableTermination. Note the underscore preceding the field name. + # Supported objects: device, rack, location, site + return queryset.filter(**{f'terminations___{name}__in': value}).distinct() + + +class CableTerminationFilterSet(BaseFilterSet): + + class Meta: + model = CableTermination + fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id'] class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -1625,7 +1647,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', @@ -1679,7 +1701,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn class Meta: model = PowerFeed - fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'] + fields = [ + 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 1ba7adf84..f7bfa6431 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,279 +1,171 @@ +from django import forms + from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.models import Tag -from netbox.forms import NetBoxModelForm -from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect - -__all__ = ( - 'ConnectCableToCircuitTerminationForm', - 'ConnectCableToConsolePortForm', - 'ConnectCableToConsoleServerPortForm', - 'ConnectCableToFrontPortForm', - 'ConnectCableToInterfaceForm', - 'ConnectCableToPowerFeedForm', - 'ConnectCableToPowerPortForm', - 'ConnectCableToPowerOutletForm', - 'ConnectCableToRearPortForm', -) +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from .models import CableForm -class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): - """ - Base form for connecting a Cable to a Device component - """ - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_device = DynamicModelChoiceField( - queryset=Device.objects.all(), - label='Device', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - 'rack_id': '$termination_b_rack', - } - ) +def get_cable_form(a_type, b_type): - class Meta: - model = Cable - fields = [ - 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack', - 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', - ] - widgets = { - 'status': StaticSelect, - 'type': StaticSelect, - 'length_unit': StaticSelect, - } + class FormMetaclass(forms.models.ModelFormMetaclass): - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + def __new__(mcs, name, bases, attrs): + for cable_end, term_cls in (('a', a_type), ('b', b_type)): -class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsolePort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False, + initial_params={ + 'sites': f'$termination_{cable_end}_site' + } + ) + attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False, + initial_params={ + 'sites': f'$termination_{cable_end}_site' + } + ) + attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + query_params={ + 'region_id': f'$termination_{cable_end}_region', + 'group_id': f'$termination_{cable_end}_sitegroup', + } + ) + attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + null_option='None', + query_params={ + 'site_id': f'$termination_{cable_end}_site' + } + ) + # Device component + if hasattr(term_cls, 'device'): -class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + null_option='None', + initial_params={ + 'devices': f'$termination_{cable_end}_device' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + } + ) + attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + initial_params={ + f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + 'rack_id': f'$termination_{cable_end}_rack', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label=term_cls._meta.verbose_name.title(), + disabled_indicator='_occupied', + query_params={ + 'device_id': f'$termination_{cable_end}_device', + } + ) + # PowerFeed + elif term_cls == PowerFeed: -class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + label='Power Panel', + required=False, + initial_params={ + 'powerfeeds__in': f'${cable_end}_terminations' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label='Power Feed', + disabled_indicator='_occupied', + query_params={ + 'powerpanel_id': f'$termination_{cable_end}_powerpanel', + } + ) + # CircuitTermination + elif term_cls == CircuitTermination: -class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerOutlet.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + initial_params={ + 'circuits': f'$termination_{cable_end}_circuit' + }, + required=False + ) + attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + initial_params={ + 'terminations__in': f'${cable_end}_terminations' + }, + query_params={ + 'provider_id': f'$termination_{cable_end}_provider', + 'site_id': f'$termination_{cable_end}_site', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label='Side', + disabled_indicator='_occupied', + query_params={ + 'circuit_id': f'termination_{cable_end}_circuit', + } + ) + return super().__new__(mcs, name, bases, attrs) -class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=Interface.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device', - 'kind': 'physical', - } - ) + class _CableForm(CableForm, metaclass=FormMetaclass): + def __init__(self, *args, **kwargs): -class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=FrontPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict() + for field_name in ('a_terminations', 'b_terminations'): + if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list: + kwargs['initial'][field_name] = [kwargs['initial'][field_name]] + super().__init__(*args, **kwargs) -class ConnectCableToRearPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=RearPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + if self.instance and self.instance.pk: + # Initialize A/B terminations when modifying an existing Cable instance + self.initial['a_terminations'] = self.instance.get_a_terminations() + self.initial['b_terminations'] = self.instance.get_b_terminations() + def save(self, *args, **kwargs): -class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): - termination_b_provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - label='Provider', - required=False - ) - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_circuit = DynamicModelChoiceField( - queryset=Circuit.objects.all(), - label='Circuit', - query_params={ - 'provider_id': '$termination_b_provider', - 'site_id': '$termination_b_site', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=CircuitTermination.objects.all(), - label='Side', - disabled_indicator='_occupied', - query_params={ - 'circuit_id': '$termination_b_circuit' - } - ) + # Set the A/B terminations on the Cable instance + self.instance.a_terminations = self.cleaned_data['a_terminations'] + self.instance.b_terminations = self.cleaned_data['b_terminations'] - class Meta(ConnectCableToDeviceForm.Meta): - fields = [ - 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', - 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', - ] + return super().save(*args, **kwargs) - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) - - -class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm): - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_powerpanel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - label='Power Panel', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=PowerFeed.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'power_panel_id': '$termination_b_powerpanel' - } - ) - - class Meta(ConnectCableToDeviceForm.Meta): - fields = [ - 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location', - 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', - 'color', 'length', 'length_unit', 'tags', - ] - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + return _CableForm diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d9bc79fb5..bd64e02b4 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -730,7 +730,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( (None, ('q', 'tag')), - ('Location', ('site_id', 'rack_id', 'device_id')), + ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -747,13 +747,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location'), + null_option='None', + query_params={ + 'site_id': '$site_id' + } + ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, label=_('Rack'), null_option='None', query_params={ - 'site_id': '$site_id' + 'site_id': '$site_id', + 'location_id': '$location_id', } ) device_id = DynamicModelMultipleChoiceField( @@ -761,8 +771,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, query_params={ 'site_id': '$site_id', - 'tenant_id': '$tenant_id', + 'location_id': '$location_id', 'rack_id': '$rack_id', + 'tenant_id': '$tenant_id', }, label=_('Device') ) diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py new file mode 100644 index 000000000..d8488aa5f --- /dev/null +++ b/netbox/dcim/graphql/mixins.py @@ -0,0 +1,5 @@ +class CabledObjectMixin: + + def resolve_cable_end(self, info): + # Handle empty values + return self.cable_end or None diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 17d6bc646..a43b293a4 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -7,6 +7,7 @@ from extras.graphql.mixins import ( from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from .mixins import CabledObjectMixin __all__ = ( 'CableType', @@ -99,7 +100,15 @@ class CableType(NetBoxObjectType): return self.length_unit or None -class ConsolePortType(ComponentObjectType): +class CableTerminationType(NetBoxObjectType): + + class Meta: + model = models.CableTermination + fields = '__all__' + filterset_class = filtersets.CableTerminationFilterSet + + +class ConsolePortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.ConsolePort @@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType): return self.type or None -class ConsoleServerPortType(ComponentObjectType): +class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.ConsoleServerPort @@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType): return self.airflow or None -class FrontPortType(ComponentObjectType): +class FrontPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.FrontPort @@ -219,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.FrontPortTemplateFilterSet -class InterfaceType(IPAddressesMixin, ComponentObjectType): +class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin): class Meta: model = models.Interface @@ -322,7 +331,7 @@ class PlatformType(OrganizationalObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(NetBoxObjectType): +class PowerFeedType(NetBoxObjectType, CabledObjectMixin): class Meta: model = models.PowerFeed @@ -330,7 +339,7 @@ class PowerFeedType(NetBoxObjectType): filterset_class = filtersets.PowerFeedFilterSet -class PowerOutletType(ComponentObjectType): +class PowerOutletType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.PowerOutlet @@ -366,7 +375,7 @@ class PowerPanelType(NetBoxObjectType): filterset_class = filtersets.PowerPanelFilterSet -class PowerPortType(ComponentObjectType): +class PowerPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.PowerPort @@ -418,7 +427,7 @@ class RackRoleType(OrganizationalObjectType): filterset_class = filtersets.RackRoleFilterSet -class RearPortType(ComponentObjectType): +class RearPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.RearPort diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index d0cd64486..4bb81bfd4 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -81,7 +81,7 @@ class Command(BaseCommand): self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...') i = 0 for i, obj in enumerate(origins, start=1): - create_cablepath(obj) + create_cablepath([obj]) if not i % 100: self.draw_progress_bar(i * 100 / origins_count) self.draw_progress_bar(100) diff --git a/netbox/dcim/migrations/0157_new_cabling_models.py b/netbox/dcim/migrations/0157_new_cabling_models.py new file mode 100644 index 000000000..a3a650086 --- /dev/null +++ b/netbox/dcim/migrations/0157_new_cabling_models.py @@ -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), + ), + ] diff --git a/netbox/dcim/migrations/0158_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py new file mode 100644 index 000000000..82f8cd359 --- /dev/null +++ b/netbox/dcim/migrations/0158_populate_cable_terminations.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0159_populate_cable_paths.py b/netbox/dcim/migrations/0159_populate_cable_paths.py new file mode 100644 index 000000000..22fe4b67e --- /dev/null +++ b/netbox/dcim/migrations/0159_populate_cable_paths.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0160_populate_cable_ends.py b/netbox/dcim/migrations/0160_populate_cable_ends.py new file mode 100644 index 000000000..0dac81df3 --- /dev/null +++ b/netbox/dcim/migrations/0160_populate_cable_ends.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0161_cabling_cleanup.py b/netbox/dcim/migrations/0161_cabling_cleanup.py new file mode 100644 index 000000000..8a1b7a09e --- /dev/null +++ b/netbox/dcim/migrations/0161_cabling_cleanup.py @@ -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', + ), + + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index dcc564717..551521c26 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,10 +1,12 @@ +import itertools from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum +from django.dispatch import Signal from django.urls import reverse from dcim.choices import * @@ -13,17 +15,21 @@ from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object from netbox.models import NetBoxModel from utilities.fields import ColorField +from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters -from .devices import Device +from wireless.models import WirelessLink from .device_components import FrontPort, RearPort - __all__ = ( 'Cable', 'CablePath', + 'CableTermination', ) +trace_paths = Signal() + + # # Cables # @@ -32,28 +38,6 @@ class Cable(NetBoxModel): """ A physical connection between two endpoints. """ - termination_a_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_a_id = models.PositiveBigIntegerField() - termination_a = GenericForeignKey( - ct_field='termination_a_type', - fk_field='termination_a_id' - ) - termination_b_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_b_id = models.PositiveBigIntegerField() - termination_b = GenericForeignKey( - ct_field='termination_b_type', - fk_field='termination_b_id' - ) type = models.CharField( max_length=50, choices=CableTypeChoices, @@ -96,31 +80,11 @@ class Cable(NetBoxModel): blank=True, null=True ) - # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by - # their associated Devices. - _termination_a_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - _termination_b_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) class Meta: - ordering = ['pk'] - unique_together = ( - ('termination_a_type', 'termination_a_id'), - ('termination_b_type', 'termination_b_id'), - ) + ordering = ('pk',) - def __init__(self, *args, **kwargs): + def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs): super().__init__(*args, **kwargs) # A copy of the PK to be used by __str__ in case the object is deleted @@ -129,19 +93,12 @@ class Cable(NetBoxModel): # Cache the original status so we can check later if it's been changed self._orig_status = self.status - @classmethod - def from_db(cls, db, field_names, values): - """ - Cache the original A and B terminations of existing Cable instances for later reference inside clean(). - """ - instance = super().from_db(db, field_names, values) - - instance._orig_termination_a_type_id = instance.termination_a_type_id - instance._orig_termination_a_id = instance.termination_a_id - instance._orig_termination_b_type_id = instance.termination_b_type_id - instance._orig_termination_b_id = instance.termination_b_id - - return instance + # Assign any *new* CableTerminations for the instance. These will replace any existing + # terminations on save(). + if a_terminations is not None: + self.a_terminations = a_terminations + if b_terminations is not None: + self.b_terminations = b_terminations def __str__(self): pk = self.pk or self._pk @@ -151,123 +108,41 @@ class Cable(NetBoxModel): return reverse('dcim:cable', args=[self.pk]) def clean(self): - from circuits.models import CircuitTermination - super().clean() - # Validate that termination A exists - if not hasattr(self, 'termination_a_type'): - raise ValidationError('Termination A type has not been specified') - try: - self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) - except ObjectDoesNotExist: - raise ValidationError({ - 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) - }) - - # Validate that termination B exists - if not hasattr(self, 'termination_b_type'): - raise ValidationError('Termination B type has not been specified') - try: - self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) - except ObjectDoesNotExist: - raise ValidationError({ - 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) - }) - - # If editing an existing Cable instance, check that neither termination has been modified. - if self.pk: - err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' - if ( - self.termination_a_type_id != self._orig_termination_a_type_id or - self.termination_a_id != self._orig_termination_a_id - ): - raise ValidationError({ - 'termination_a': err_msg - }) - if ( - self.termination_b_type_id != self._orig_termination_b_type_id or - self.termination_b_id != self._orig_termination_b_id - ): - raise ValidationError({ - 'termination_b': err_msg - }) - - type_a = self.termination_a_type.model - type_b = self.termination_b_type.model - - # Validate interface types - if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_a.get_type_display() - ) - }) - if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_b.get_type_display() - ) - }) - - # Check that termination types are compatible - if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): - raise ValidationError( - f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" - ) - - # Check that two connected RearPorts have the same number of positions (if both are >1) - if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort): - if self.termination_a.positions > 1 and self.termination_b.positions > 1: - if self.termination_a.positions != self.termination_b.positions: - raise ValidationError( - f"{self.termination_a} has {self.termination_a.positions} position(s) but " - f"{self.termination_b} has {self.termination_b.positions}. " - f"Both terminations must have the same number of positions (if greater than one)." - ) - - # A termination point cannot be connected to itself - if self.termination_a == self.termination_b: - raise ValidationError(f"Cannot connect {self.termination_a_type} to itself") - - # A front port cannot be connected to its corresponding rear port - if ( - type_a in ['frontport', 'rearport'] and - type_b in ['frontport', 'rearport'] and - ( - getattr(self.termination_a, 'rear_port', None) == self.termination_b or - getattr(self.termination_b, 'rear_port', None) == self.termination_a - ) - ): - raise ValidationError("A front port cannot be connected to it corresponding rear port") - - # A CircuitTermination attached to a ProviderNetwork cannot have a Cable - if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None: - raise ValidationError({ - 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled." - }) - if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None: - raise ValidationError({ - 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled." - }) - - # Check for an existing Cable connected to either termination object - if self.termination_a.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_a, self.termination_a.cable_id - )) - if self.termination_b.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_b, self.termination_b.cable_id - )) - # Validate length and length_unit if self.length is not None and not self.length_unit: raise ValidationError("Must specify a unit when setting a cable length") elif self.length is None: self.length_unit = '' + a_terminations = [ + CableTermination(cable=self, cable_end='A', termination=t) + for t in getattr(self, 'a_terminations', []) + ] + b_terminations = [ + CableTermination(cable=self, cable_end='B', termination=t) + for t in getattr(self, 'b_terminations', []) + ] + + # Check that all termination objects for either end are of the same type + for terms in (a_terminations, b_terminations): + if len(terms) > 1 and not all(t.termination_type == terms[0].termination_type for t in terms[1:]): + raise ValidationError("Cannot connect different termination types to same end of cable.") + + # Check that termination types are compatible + if a_terminations and b_terminations: + a_type = a_terminations[0].termination_type.model + b_type = b_terminations[0].termination_type.model + if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): + raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + + # Run clean() on any new CableTerminations + for cabletermination in [*a_terminations, *b_terminations]: + cabletermination.clean() + def save(self, *args, **kwargs): + _created = self.pk is None # Store the given length (if any) in meters for use in database ordering if self.length and self.length_unit: @@ -275,199 +150,454 @@ class Cable(NetBoxModel): else: self._abs_length = None - # Store the parent Device for the A and B terminations (if applicable) to enable filtering - if hasattr(self.termination_a, 'device'): - self._termination_a_device = self.termination_a.device - if hasattr(self.termination_b, 'device'): - self._termination_b_device = self.termination_b.device - super().save(*args, **kwargs) # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) self._pk = self.pk + # Retrieve existing A/B terminations for the Cable + a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')} + b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')} + + # Delete stale CableTerminations + if hasattr(self, 'a_terminations'): + for termination, ct in a_terminations.items(): + if termination not in self.a_terminations: + ct.delete() + if hasattr(self, 'b_terminations'): + for termination, ct in b_terminations.items(): + if termination not in self.b_terminations: + ct.delete() + + # Save new CableTerminations (if any) + if hasattr(self, 'a_terminations'): + for termination in self.a_terminations: + if termination not in a_terminations: + CableTermination(cable=self, cable_end='A', termination=termination).save() + if hasattr(self, 'b_terminations'): + for termination in self.b_terminations: + if termination not in b_terminations: + CableTermination(cable=self, cable_end='B', termination=termination).save() + + trace_paths.send(Cable, instance=self, created=_created) + def get_status_color(self): return LinkStatusChoices.colors.get(self.status) - def get_compatible_types(self): + def get_a_terminations(self): + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A + ] + + def get_b_terminations(self): + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B + ] + + +class CableTermination(models.Model): + """ + A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination). + """ + cable = models.ForeignKey( + to='dcim.Cable', + on_delete=models.CASCADE, + related_name='terminations' + ) + cable_end = models.CharField( + max_length=1, + choices=CableEndChoices, + verbose_name='End' + ) + termination_type = models.ForeignKey( + to=ContentType, + limit_choices_to=CABLE_TERMINATION_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + termination_id = models.PositiveBigIntegerField() + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) + + # Cached associations to enable efficient filtering + _device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('cable', 'cable_end', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('termination_type', 'termination_id'), + name='dcim_cable_termination_unique_termination' + ), + ) + + def __str__(self): + return f'Cable {self.cable} to {self.termination}' + + def clean(self): + super().clean() + + # Validate interface type (if applicable) + if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' + }) + + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: + raise ValidationError({ + 'termination': "Circuit terminations attached to a provider network may not be cabled." + }) + + def save(self, *args, **kwargs): + + # Cache objects associated with the terminating object (for filtering) + self.cache_related_objects() + + super().save(*args, **kwargs) + + # Set the cable on the terminating object + termination_model = self.termination._meta.model + termination_model.objects.filter(pk=self.termination_id).update( + cable=self.cable, + cable_end=self.cable_end + ) + + def delete(self, *args, **kwargs): + + # Delete the cable association on the terminating object + termination_model = self.termination._meta.model + termination_model.objects.filter(pk=self.termination_id).update( + cable=None, + cable_end='' + ) + + super().delete(*args, **kwargs) + + def cache_related_objects(self): """ - Return all termination types compatible with termination A. + Cache objects related to the termination (e.g. device, rack, site) directly on the object to + enable efficient filtering. """ - if self.termination_a is None: - return - return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + assert self.termination is not None + + # Device components + if getattr(self.termination, 'device', None): + self._device = self.termination.device + self._rack = self.termination.device.rack + self._location = self.termination.device.location + self._site = self.termination.device.site + + # Power feeds + elif getattr(self.termination, 'rack', None): + self._rack = self.termination.rack + self._location = self.termination.rack.location + self._site = self.termination.rack.site + + # Circuit terminations + elif getattr(self.termination, 'site', None): + self._site = self.termination.site class CablePath(models.Model): """ - A CablePath instance represents the physical path from an origin to a destination, including all intermediate - elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do - not terminate on a PathEndpoint). + A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes, + including all intermediate elements. - `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the - path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following + `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can + terminate to one or more objects.) For example, consider the following topology: - 1 2 3 - Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B + A B C + Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2 + Front Port 2 Front Port 4 This path would be expressed as: CablePath( - origin = Interface A - destination = Interface B - path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] + path = [ + [Interface 1], + [Cable A], + [Front Port 1, Front Port 2], + [Rear Port 1], + [Cable B], + [Rear Port 2], + [Front Port 3, Front Port 4], + [Cable C], + [Interface 2], + ] ) - `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of - "connected". + `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True + if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the + path diverges across multiple cables. + + `_nodes` retains a flattened list of all nodes within the path to enable simple filtering. """ - origin_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+' + path = models.JSONField( + default=list ) - origin_id = models.PositiveBigIntegerField() - origin = GenericForeignKey( - ct_field='origin_type', - fk_field='origin_id' - ) - destination_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - destination_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - destination = GenericForeignKey( - ct_field='destination_type', - fk_field='destination_id' - ) - path = PathField() is_active = models.BooleanField( default=False ) + is_complete = models.BooleanField( + default=False + ) is_split = models.BooleanField( default=False ) - - class Meta: - unique_together = ('origin_type', 'origin_id') + _nodes = PathField() def __str__(self): - status = ' (active)' if self.is_active else ' (split)' if self.is_split else '' - return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}" + return f"Path #{self.pk}: {len(self.path)} hops" def save(self, *args, **kwargs): + + # Save the flattened nodes list + self._nodes = list(itertools.chain(*self.path)) + super().save(*args, **kwargs) - # Record a direct reference to this CablePath on its originating object - model = self.origin._meta.model - model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + # Record a direct reference to this CablePath on its originating object(s) + origin_model = self.origin_type.model_class() + origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] + origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk) + + @property + def origin_type(self): + if self.path: + ct_id, _ = decompile_path_node(self.path[0][0]) + return ContentType.objects.get_for_id(ct_id) + + @property + def destination_type(self): + if self.is_complete: + ct_id, _ = decompile_path_node(self.path[-1][0]) + return ContentType.objects.get_for_id(ct_id) + + @property + def path_objects(self): + """ + Cache and return the complete path as lists of objects, derived from their annotation within the path. + """ + if not hasattr(self, '_path_objects'): + self._path_objects = self._get_path() + return self._path_objects + + @property + def origins(self): + """ + Return the list of originating objects. + """ + if hasattr(self, '_path_objects'): + return self.path_objects[0] + return [ + path_node_to_object(node) for node in self.path[0] + ] + + @property + def destinations(self): + """ + Return the list of destination objects, if the path is complete. + """ + if not self.is_complete: + return [] + if hasattr(self, '_path_objects'): + return self.path_objects[-1] + return [ + path_node_to_object(node) for node in self.path[-1] + ] @property def segment_count(self): - total_length = 1 + len(self.path) + (1 if self.destination else 0) - return int(total_length / 3) + return int(len(self.path) / 3) @classmethod - def from_origin(cls, origin): + def from_origin(cls, terminations): """ - Create a new CablePath instance as traced from the given path origin. + Create a new CablePath instance as traced from the given termination objects. These can be any object to which a + Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be + of the same type and must belong to the same parent object. """ from circuits.models import CircuitTermination - if origin is None or origin.link is None: - return None - - destination = None path = [] position_stack = [] + is_complete = False is_active = True is_split = False - node = origin - while node.link is not None: - if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED: + while terminations: + + # Terminations must all be of the same type + assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + + # Step 1: Record the near-end termination object(s) + path.append([ + object_to_path_node(t) for t in terminations + ]) + + # Step 2: Determine the attached link (Cable or WirelessLink), if any + link = terminations[0].link + assert all(t.link == link for t in terminations[1:]) + if link is None and len(path) == 1: + # If this is the start of the path and no link exists, return None + return None + elif link is None: + # Otherwise, halt the trace if no link exists + break + assert type(link) in (Cable, WirelessLink) + + # Step 3: Record the link and update path status if not "connected" + path.append([object_to_path_node(link)]) + if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: is_active = False - # Follow the link to its far-end termination - path.append(object_to_path_node(node.link)) - peer_termination = node.get_link_peer() + # Step 4: Determine the far-end terminations + if isinstance(link, Cable): + termination_type = ContentType.objects.get_for_model(terminations[0]) + local_cable_terminations = CableTermination.objects.filter( + termination_type=termination_type, + termination_id__in=[t.pk for t in terminations] + ) + # Terminations must all belong to same end of Cable + local_cable_end = local_cable_terminations[0].cable_end + assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) + remote_cable_terminations = CableTermination.objects.filter( + cable=link, + cable_end='A' if local_cable_end == 'B' else 'B' + ) + remote_terminations = [ct.termination for ct in remote_cable_terminations] + else: + # WirelessLink + remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] - # Follow a FrontPort to its corresponding RearPort - if isinstance(peer_termination, FrontPort): - path.append(object_to_path_node(peer_termination)) - node = peer_termination.rear_port - if node.positions > 1: - position_stack.append(peer_termination.rear_port_position) - path.append(object_to_path_node(node)) + # Step 5: Record the far-end termination object(s) + path.append([ + object_to_path_node(t) for t in remote_terminations + ]) - # Follow a RearPort to its corresponding FrontPort (if any) - elif isinstance(peer_termination, RearPort): - path.append(object_to_path_node(peer_termination)) + # Step 6: Determine the "next hop" terminations, if applicable + if isinstance(remote_terminations[0], FrontPort): + # Follow FrontPorts to their corresponding RearPorts + rear_ports = RearPort.objects.filter( + pk__in=[t.rear_port_id for t in remote_terminations] + ) + if len(rear_ports) > 1: + assert all(rp.positions == 1 for rp in rear_ports) + elif rear_ports[0].positions > 1: + position_stack.append([fp.rear_port_position for fp in remote_terminations]) - # Determine the peer FrontPort's position - if peer_termination.positions == 1: - position = 1 + terminations = rear_ports + + elif isinstance(remote_terminations[0], RearPort): + + if len(remote_terminations) > 1 or remote_terminations[0].positions == 1: + front_ports = FrontPort.objects.filter( + rear_port_id__in=[rp.pk for rp in remote_terminations], + rear_port_position=1 + ) elif position_stack: - position = position_stack.pop() + front_ports = FrontPort.objects.filter( + rear_port_id=remote_terminations[0].pk, + rear_port_position__in=position_stack.pop() + ) else: - # No position indicated: path has split, so we stop at the RearPort + # No position indicated: path has split, so we stop at the RearPorts is_split = True break - try: - node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) - path.append(object_to_path_node(node)) - except ObjectDoesNotExist: - # No corresponding FrontPort found for the RearPort + terminations = front_ports + + elif isinstance(remote_terminations[0], CircuitTermination): + # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) + term_side = remote_terminations[0].term_side + assert all(ct.term_side == term_side for ct in remote_terminations[1:]) + circuit_termination = CircuitTermination.objects.filter( + circuit=remote_terminations[0].circuit, + term_side='Z' if term_side == 'A' else 'A' + ).first() + if circuit_termination is None: + break + elif circuit_termination.provider_network: + # Circuit terminates to a ProviderNetwork + path.extend([ + [object_to_path_node(circuit_termination)], + [object_to_path_node(circuit_termination.provider_network)], + ]) + break + elif circuit_termination.site and not circuit_termination.cable: + # Circuit terminates to a Site + path.extend([ + [object_to_path_node(circuit_termination)], + [object_to_path_node(circuit_termination.site)], + ]) break - # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) - elif isinstance(peer_termination, CircuitTermination): - path.append(object_to_path_node(peer_termination)) - # Get peer CircuitTermination - node = peer_termination.get_peer_termination() - if node: - path.append(object_to_path_node(node)) - if node.provider_network: - destination = node.provider_network - break - elif node.site and not node.cable: - destination = node.site - break - else: - # No peer CircuitTermination exists; halt the trace - break + terminations = [circuit_termination] # Anything else marks the end of the path else: - destination = peer_termination + is_complete = True break - if destination is None: - is_active = False - return cls( - origin=origin, - destination=destination, path=path, + is_complete=is_complete, is_active=is_active, is_split=is_split ) - def get_path(self): + def retrace(self): + """ + Retrace the path from the currently-defined originating termination(s) + """ + _new = self.from_origin(self.origins) + if _new: + self.path = _new.path + self.is_complete = _new.is_complete + self.is_active = _new.is_active + self.is_split = _new.is_split + self.save() + else: + self.delete() + + def _get_path(self): """ Return the path as a list of prefetched objects. """ # Compile a list of IDs to prefetch for each type of model in the path to_prefetch = defaultdict(list) - for node in self.path: + for node in self._nodes: ct_id, object_id = decompile_path_node(node) to_prefetch[ct_id].append(object_id) @@ -484,19 +614,15 @@ class CablePath(models.Model): # Replicate the path using the prefetched objects. path = [] - for node in self.path: - ct_id, object_id = decompile_path_node(node) - path.append(prefetched[ct_id][object_id]) + for step in self.path: + nodes = [] + for node in step: + ct_id, object_id = decompile_path_node(node) + nodes.append(prefetched[ct_id][object_id]) + path.append(nodes) return path - @property - def last_node(self): - """ - Return either the destination or the last node within the path. - """ - return self.destination or path_node_to_object(self.path[-1]) - def get_cable_ids(self): """ Return all Cable IDs within the path. @@ -504,7 +630,7 @@ class CablePath(models.Model): cable_ct = ContentType.objects.get_for_model(Cable).pk cable_ids = [] - for node in self.path: + for node in self._nodes: ct, id = decompile_path_node(node) if ct == cable_ct: cable_ids.append(id) @@ -527,6 +653,6 @@ class CablePath(models.Model): """ Return all available next segments in a split cable path. """ - rearport = path_node_to_object(self.path[-1]) + rearport = path_node_to_object(self._nodes[-1]) return FrontPort.objects.filter(rear_port=rearport) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 70c21c165..82dacbff6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,6 +1,8 @@ +from functools import cached_property + from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum @@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField -from dcim.svg import CableTraceSVG from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr __all__ = ( 'BaseInterface', - 'LinkTermination', + 'CabledObjectModel', 'ConsolePort', 'ConsoleServerPort', 'DeviceBay', @@ -103,14 +104,10 @@ class ModularComponentModel(ComponentModel): abstract = True -class LinkTermination(models.Model): +class CabledObjectModel(models.Model): """ - An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples - include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields - reference the attached Cable or WirelessLink instance, respectively. - - `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a - shortcut to referencing `instance.link.termination_b`, for example. + An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end` + fields for caching cable associations, as well as `mark_connected` to designate "fake" connections. """ cable = models.ForeignKey( to='dcim.Cable', @@ -119,36 +116,21 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_type = models.ForeignKey( - to=ContentType, - on_delete=models.SET_NULL, - related_name='+', + cable_end = models.CharField( + max_length=1, blank=True, - null=True - ) - _link_peer_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - _link_peer = GenericForeignKey( - ct_field='_link_peer_type', - fk_field='_link_peer_id' + choices=CableEndChoices ) mark_connected = models.BooleanField( default=False, help_text="Treat as if a cable is connected" ) - # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. - _cabled_as_a = GenericRelation( - to='dcim.Cable', - content_type_field='termination_a_type', - object_id_field='termination_a_id' - ) - _cabled_as_b = GenericRelation( - to='dcim.Cable', - content_type_field='termination_b_type', - object_id_field='termination_b_id' + cable_terminations = GenericRelation( + to='dcim.CableTermination', + content_type_field='termination_type', + object_id_field='termination_id', + related_query_name='%(class)s', ) class Meta: @@ -157,22 +139,19 @@ class LinkTermination(models.Model): def clean(self): super().clean() - if self.mark_connected and self.cable_id: + if self.cable and not self.cable_end: + raise ValidationError({ + "cable_end": "Must specify cable end (A or B) when attaching a cable." + }) + if self.cable_end and not self.cable: + raise ValidationError({ + "cable_end": "Cable end must not be set without a cable." + }) + if self.mark_connected and self.cable: raise ValidationError({ "mark_connected": "Cannot mark as connected with a cable attached." }) - def get_link_peer(self): - return self._link_peer - - @property - def _occupied(self): - return bool(self.mark_connected or self.cable_id) - - @property - def parent_object(self): - raise NotImplementedError("CableTermination models must implement parent_object()") - @property def link(self): """ @@ -180,10 +159,31 @@ class LinkTermination(models.Model): """ return self.cable + @cached_property + def link_peers(self): + if self.cable: + peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination') + return [peer.termination for peer in peers] + return [] + + @property + def _occupied(self): + return bool(self.mark_connected or self.cable_id) + + @property + def parent_object(self): + raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property") + + @property + def opposite_cable_end(self): + if not self.cable_end: + return None + return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B + class PathEndpoint(models.Model): """ - An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, + An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically, these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in @@ -206,50 +206,41 @@ class PathEndpoint(models.Model): origin = self path = [] - # Construct the complete path + # Construct the complete path (including e.g. bridged interfaces) while origin is not None: if origin._path is None: break - path.extend([origin, *origin._path.get_path()]) - while (len(path) + 1) % 3: + path.extend(origin._path.path_objects) + while (len(path)) % 3: # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) - path.append(None) - path.append(origin._path.destination) + # by inserting empty entries immediately prior to the path's destination node(s) + path.append([]) - # Check for bridge interface to continue the trace - origin = getattr(origin._path.destination, 'bridge', None) + # Check for a bridged relationship to continue the trace + destinations = origin._path.destinations + if len(destinations) == 1: + origin = getattr(destinations[0], 'bridge', None) + else: + origin = None - # Return the path as a list of three-tuples (A termination, cable, B termination) + # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s)) return list(zip(*[iter(path)] * 3)) - def get_trace_svg(self, base_url=None, width=None): - if width is not None: - trace = CableTraceSVG(self, base_url=base_url, width=width) - else: - trace = CableTraceSVG(self, base_url=base_url) - return trace.render() - - @property - def path(self): - return self._path - - @property - def connected_endpoint(self): + @cached_property + def connected_endpoints(self): """ Caching accessor for the attached CablePath's destination (if any) """ - if not hasattr(self, '_connected_endpoint'): - self._connected_endpoint = self._path.destination if self._path else None - return self._connected_endpoint + return self._path.destinations if self._path else [] # # Console components # -class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -276,7 +267,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -307,7 +298,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -348,36 +339,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." }) + def get_downstream_powerports(self, leg=None): + """ + Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology + below, PP1.get_downstream_powerports() would return PP2-4. + + ---- PO1 <---> PP2 + / + PP1 ------- PO2 <---> PP3 + \ + ---- PO3 <---> PP4 + + """ + poweroutlets = self.poweroutlets.filter(cable__isnull=False) + if leg: + poweroutlets = poweroutlets.filter(feed_leg=leg) + if not poweroutlets: + return PowerPort.objects.none() + + q = Q() + for poweroutlet in poweroutlets: + q |= Q( + cable=poweroutlet.cable, + cable_end=poweroutlet.opposite_cable_end + ) + + return PowerPort.objects.filter(q) + def get_power_draw(self): """ Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. """ + from dcim.models import PowerFeed + # Calculate aggregate draw of all child power outlets if no numbers have been defined manually if self.allocated_draw is None and self.maximum_draw is None: - poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) - outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) - utilization = PowerPort.objects.filter( - _link_peer_type=poweroutlet_ct, - _link_peer_id__in=outlet_ids - ).aggregate( + utilization = self.get_downstream_powerports().aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), ) ret = { 'allocated': utilization['allocated_draw_total'] or 0, 'maximum': utilization['maximum_draw_total'] or 0, - 'outlet_count': len(outlet_ids), + 'outlet_count': self.poweroutlets.count(), 'legs': [], } - # Calculate per-leg aggregates for three-phase feeds - if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: + # Calculate per-leg aggregates for three-phase power feeds + if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \ + self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE: for leg, leg_name in PowerOutletFeedLegChoices: - outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) - utilization = PowerPort.objects.filter( - _link_peer_type=poweroutlet_ct, - _link_peer_id__in=outlet_ids - ).aggregate( + utilization = self.get_downstream_powerports(leg=leg).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), ) @@ -385,7 +397,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): 'name': leg_name, 'allocated': utilization['allocated_draw_total'] or 0, 'maximum': utilization['maximum_draw_total'] or 0, - 'outlet_count': len(outlet_ids), + 'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(), }) return ret @@ -394,12 +406,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): return { 'allocated': self.allocated_draw or 0, 'maximum': self.maximum_draw or 0, - 'outlet_count': PowerOutlet.objects.filter(power_port=self).count(), + 'outlet_count': self.poweroutlets.count(), 'legs': [], } -class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -437,9 +449,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): # Validate power port assignment if self.power_port and self.power_port.device != self.device: - raise ValidationError( - "Parent power port ({}) must belong to the same device".format(self.power_port) - ) + raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device") # @@ -513,7 +523,7 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -829,6 +839,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def link(self): return self.cable or self.wireless_link + @cached_property + def link_peers(self): + if self.cable: + return super().link_peers + if self.wireless_link: + # Return the opposite side of the attached wireless link + if self.wireless_link.interface_a == self: + return [self.wireless_link.interface_b] + else: + return [self.wireless_link.interface_a] + return [] + @property def l2vpn_termination(self): return self.l2vpn_terminations.first() @@ -838,7 +860,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo # Pass-through ports # -class FrontPort(ModularComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, CabledObjectModel): """ A pass-through port on the front of a Device. """ @@ -891,7 +913,7 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -class RearPort(ModularComponentModel, LinkTermination): +class RearPort(ModularComponentModel, CabledObjectModel): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 5978d86bd..94767c6c4 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -9,7 +9,7 @@ from dcim.constants import * from netbox.config import ConfigItem from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator -from .device_components import LinkTermination, PathEndpoint +from .device_components import CabledObjectModel, PathEndpoint __all__ = ( 'PowerFeed', @@ -67,7 +67,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 31fbb71de..2039def09 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -432,17 +432,17 @@ class Rack(NetBoxModel): if not available_power_total: return 0 - pf_powerports = PowerPort.objects.filter( - _link_peer_type=ContentType.objects.get_for_model(PowerFeed), - _link_peer_id__in=powerfeeds.values_list('id', flat=True) - ) - poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) - allocated_draw_total = PowerPort.objects.filter( - _link_peer_type=ContentType.objects.get_for_model(PowerOutlet), - _link_peer_id__in=poweroutlets.values_list('id', flat=True) - ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 + powerports = [] + for powerfeed in powerfeeds: + powerports.extend([ + peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort) + ]) - return int(allocated_draw_total / available_power_total * 100) + allocated_draw = sum([ + powerport.get_power_draw()['allocated'] for powerport in powerports + ]) + + return int(allocated_draw / available_power_total * 100) class RackReservation(NetBoxModel): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 79e9c6687..7cfdc823d 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,11 +1,11 @@ import logging -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import LinkStatusChoices -from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .choices import CableEndChoices, LinkStatusChoices +from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths @@ -68,73 +68,55 @@ def clear_virtualchassis_members(instance, **kwargs): # Cables # - -@receiver(post_save, sender=Cable) +@receiver(trace_paths, sender=Cable) def update_connected_endpoints(instance, created, raw=False, **kwargs): """ - When a Cable is saved, check for and update its two connected endpoints + When a Cable is saved with new terminations, retrace any affected cable paths. """ logger = logging.getLogger('netbox.dcim.cable') if raw: logger.debug(f"Skipping endpoint updates for imported cable {instance}") return - # Cache the Cable on its two termination points - if instance.termination_a.cable != instance: - logger.debug(f"Updating termination A for cable {instance}") - instance.termination_a.cable = instance - instance.termination_a._link_peer = instance.termination_b - instance.termination_a.save() - if instance.termination_b.cable != instance: - logger.debug(f"Updating termination B for cable {instance}") - instance.termination_b.cable = instance - instance.termination_b._link_peer = instance.termination_a - instance.termination_b.save() - - # Create/update cable paths - if created: - for termination in (instance.termination_a, instance.termination_b): - if isinstance(termination, PathEndpoint): - create_cablepath(termination) + # Update cable paths if new terminations have been set + if hasattr(instance, 'a_terminations') or hasattr(instance, 'b_terminations'): + a_terminations = [] + b_terminations = [] + for t in instance.terminations.all(): + if t.cable_end == CableEndChoices.SIDE_A: + a_terminations.append(t.termination) else: - rebuild_paths(termination) + b_terminations.append(t.termination) + for nodes in [a_terminations, b_terminations]: + # Examine type of first termination to determine object type (all must be the same) + if not nodes: + continue + if isinstance(nodes[0], PathEndpoint): + create_cablepath(nodes) + else: + rebuild_paths(nodes) + + # Update status of CablePaths if Cable status has been changed elif instance.status != instance._orig_status: - # We currently don't support modifying either termination of an existing Cable. (This - # may change in the future.) However, we do need to capture status changes and update - # any CablePaths accordingly. if instance.status != LinkStatusChoices.STATUS_CONNECTED: - CablePath.objects.filter(path__contains=instance).update(is_active=False) + CablePath.objects.filter(_nodes__contains=instance).update(is_active=False) else: - rebuild_paths(instance) + rebuild_paths([instance]) @receiver(post_delete, sender=Cable) +def retrace_cable_paths(instance, **kwargs): + """ + When a Cable is deleted, check for and update its connected endpoints + """ + for cablepath in CablePath.objects.filter(_nodes__contains=instance): + cablepath.retrace() + + +@receiver(post_delete, sender=CableTermination) def nullify_connected_endpoints(instance, **kwargs): """ - When a Cable is deleted, check for and update its two connected endpoints + Disassociate the Cable from the termination object. """ - logger = logging.getLogger('netbox.dcim.cable') - - # Disassociate the Cable from its termination points - if instance.termination_a is not None: - logger.debug(f"Nullifying termination A for cable {instance}") - model = instance.termination_a._meta.model - model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None) - if instance.termination_b is not None: - logger.debug(f"Nullifying termination B for cable {instance}") - model = instance.termination_b._meta.model - model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None) - - # Delete and retrace any dependent cable paths - for cablepath in CablePath.objects.filter(path__contains=instance): - cp = CablePath.from_origin(cablepath.origin) - if cp: - CablePath.objects.filter(pk=cablepath.pk).update( - path=cp.path, - destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None, - destination_id=cp.destination.pk if cp.destination else None, - is_active=cp.is_active, - is_split=cp.is_split - ) - else: - cablepath.delete() + model = instance.termination_type.model_class() + model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index eb0d2aca1..f9c614b67 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -1,12 +1,14 @@ import svgwrite - -from django.conf import settings from svgwrite.container import Group, Hyperlink -from svgwrite.shapes import Line, Rect +from svgwrite.shapes import Line, Polyline, Rect from svgwrite.text import Text +from django.conf import settings + +from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from utilities.utils import foreground_color + __all__ = ( 'CableTraceSVG', ) @@ -15,6 +17,95 @@ __all__ = ( OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 +FANOUT_HEIGHT = 35 +FANOUT_LEG_HEIGHT = 15 + + +class Node(Hyperlink): + """ + Create a node to be represented in the SVG document as a rectangular box with a hyperlink. + + Arguments: + position: (x, y) coordinates of the box's top left corner + width: Box width + url: Hyperlink URL + color: Box fill color (RRGGBB format) + labels: An iterable of text strings. Each label will render on a new line within the box. + radius: Box corner radius, for rounded corners (default: 10) + """ + + def __init__(self, position, width, url, color, labels, radius=10, **extra): + super(Node, self).__init__(href=url, target='_blank', **extra) + + x, y = position + + # Add the box + dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING) + box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}') + self.add(box) + + cursor = y + PADDING + + # Add text label(s) + for i, label in enumerate(labels): + cursor += LINE_HEIGHT + text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2) + text_color = f'#{foreground_color(color, dark="303030")}' + text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) + self.add(text) + + @property + def box(self): + return self.elements[0] if self.elements else None + + @property + def top_center(self): + return self.box['x'] + self.box['width'] / 2, self.box['y'] + + @property + def bottom_center(self): + return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height'] + + +class Connector(Group): + """ + Return an SVG group containing a line element and text labels representing a Cable. + + Arguments: + color: Cable (line) color + url: Hyperlink URL + labels: Iterable of text labels + """ + + def __init__(self, start, url, color, labels=[], **extra): + super().__init__(class_='connector', **extra) + + self.start = start + self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 + self.end = (start[0], start[1] + self.height) + self.color = color or '000000' + + # Draw a "shadow" line to give the cable a border + cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') + self.add(cable_shadow) + + # Draw the cable + cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') + self.add(cable) + + # Add link + link = Hyperlink(href=url, target='_blank') + + # Add text label(s) + cursor = start[1] + cursor += PADDING * 2 + for i, label in enumerate(labels): + cursor += LINE_HEIGHT + text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) + text = Text(label, insert=text_coords, class_='bold' if not i else []) + link.add(text) + + self.add(link) class CableTraceSVG: @@ -25,7 +116,7 @@ class CableTraceSVG: :param width: Width of the generated image (in pixels) :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, origin, width=400, base_url=None): + def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None): self.origin = origin self.width = width self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -34,6 +125,11 @@ class CableTraceSVG: # Center edges on pixels to render sharp borders self.cursor = OFFSET + # Prep elements lists + self.parent_objects = [] + self.terminations = [] + self.connectors = [] + @property def center(self): return self.width / 2 @@ -78,95 +174,103 @@ class CableTraceSVG: # Other parent object return 'e0e0e0' - def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10): + def draw_parent_objects(self, obj_list): """ - Return an SVG Link element containing a Rect and one or more text labels representing a - parent object or cable termination point. - - :param width: Box width - :param color: Box fill color - :param url: Hyperlink URL - :param labels: Iterable of text labels - :param y_indent: Vertical indent (for overlapping other boxes) (default: 0) - :param padding_multiplier: Add extra vertical padding (default: 1) - :param radius: Box corner radius (default: 10) + Draw a set of parent objects. """ - self.cursor -= y_indent + width = self.width / len(obj_list) + for i, obj in enumerate(obj_list): + node = Node( + position=(i * width, self.cursor), + width=width, + url=f'{self.base_url}{obj.get_absolute_url()}', + color=self._get_color(obj), + labels=self._get_labels(obj) + ) + self.parent_objects.append(node) + if i + 1 == len(obj_list): + self.cursor += node.box['height'] - # Create a hyperlink - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') + def draw_terminations(self, terminations): + """ + Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable. + """ + nodes = [] + nodes_height = 0 + width = self.width / len(terminations) - # Add the box - position = ( - OFFSET + (self.width - width) / 2, - self.cursor + for i, term in enumerate(terminations): + node = Node( + position=(i * width, self.cursor), + width=width, + url=f'{self.base_url}{term.get_absolute_url()}', + color=self._get_color(term), + labels=self._get_labels(term), + radius=5 + ) + nodes_height = max(nodes_height, node.box['height']) + nodes.append(node) + + self.cursor += nodes_height + self.terminations.extend(nodes) + + return nodes + + def draw_fanin(self, node, connector): + points = ( + node.bottom_center, + (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT), + connector.start, ) - height = PADDING * padding_multiplier \ - + LINE_HEIGHT * len(labels) \ - + PADDING * padding_multiplier - box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}') - link.add(box) - self.cursor += PADDING * padding_multiplier + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{connector.color}'), + )) - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center, self.cursor - LINE_HEIGHT / 2) - text_color = f'#{foreground_color(color, dark="303030")}' - text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) - link.add(text) + def draw_fanout(self, node, connector): + points = ( + connector.end, + (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT), + node.top_center, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{connector.color}'), + )) - self.cursor += PADDING * padding_multiplier + def draw_cable(self, cable): + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + connector = Connector( + start=(self.center + OFFSET, self.cursor), + color=cable.color or '000000', + url=f'{self.base_url}{cable.get_absolute_url()}', + labels=labels + ) - return link + self.cursor += connector.height - def _draw_cable(self, color, url, labels): - """ - Return an SVG group containing a line element and text labels representing a Cable. + return connector - :param color: Cable (line) color - :param url: Hyperlink URL - :param labels: Iterable of text labels - """ - group = Group(class_='connector') - - # Draw a "shadow" line to give the cable a border - start = (OFFSET + self.center, self.cursor) - height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - end = (start[0], start[1] + height) - cable_shadow = Line(start=start, end=end, class_='cable-shadow') - group.add(cable_shadow) - - # Draw the cable - cable = Line(start=start, end=end, style=f'stroke: #{color}') - group.add(cable) - - self.cursor += PADDING * 2 - - # Add link - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) - text = Text(label, insert=text_coords, class_='bold' if not i else []) - link.add(text) - - group.add(link) - self.cursor += PADDING * 2 - - return group - - def _draw_wirelesslink(self, url, labels): + def draw_wirelesslink(self, wirelesslink): """ Draw a line with labels representing a WirelessLink. - - :param url: Hyperlink URL - :param labels: Iterable of text labels """ group = Group(class_='connector') + labels = [ + f'Wireless link {wirelesslink}', + wirelesslink.get_status_display() + ] + if wirelesslink.ssid: + labels.append(wirelesslink.ssid) + # Draw the wireless link start = (OFFSET + self.center, self.cursor) height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 @@ -177,7 +281,7 @@ class CableTraceSVG: self.cursor += PADDING * 2 # Add link - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') + link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank') # Add text label(s) for i, label in enumerate(labels): @@ -191,7 +295,7 @@ class CableTraceSVG: return group - def _draw_attachment(self): + def draw_attachment(self): """ Return an SVG group containing a line element and "Attachment" label. """ @@ -216,109 +320,63 @@ class CableTraceSVG: traced_path = self.origin.trace() - # Prep elements list - parent_objects = [] - terminations = [] - connectors = [] - - # Iterate through each (term, cable, term) segment in the path + # Iterate through each (terms, cable, terms) segment in the path for i, segment in enumerate(traced_path): - near_end, connector, far_end = segment + near_ends, links, far_ends = segment # Near end parent if i == 0: # If this is the first segment, draw the originating termination's parent object - parent_object = self._draw_box( - width=self.width, - color=self._get_color(near_end.parent_object), - url=near_end.parent_object.get_absolute_url(), - labels=self._get_labels(near_end.parent_object), - padding_multiplier=2 - ) - parent_objects.append(parent_object) + self.draw_parent_objects(set(end.parent_object for end in near_ends)) - # Near end termination - if near_end is not None: - termination = self._draw_box( - width=self.width * .8, - color=self._get_color(near_end), - url=near_end.get_absolute_url(), - labels=self._get_labels(near_end), - y_indent=PADDING, - radius=5 - ) - terminations.append(termination) + # Near end termination(s) + terminations = self.draw_terminations(near_ends) # Connector (a Cable or WirelessLink) - if connector is not None: + if links: + link = links[0] # Remove Cable from list # Cable - if type(connector) is Cable: - connector_labels = [ - f'Cable {connector}', - connector.get_status_display() - ] - if connector.type: - connector_labels.append(connector.get_type_display()) - if connector.length and connector.length_unit: - connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}') - cable = self._draw_cable( - color=connector.color or '000000', - url=connector.get_absolute_url(), - labels=connector_labels - ) - connectors.append(cable) + if type(link) is Cable: + + # Account for fan-ins height + if len(near_ends) > 1: + self.cursor += FANOUT_HEIGHT + + cable = self.draw_cable(link) + self.connectors.append(cable) + + # Draw fan-ins + if len(near_ends) > 1: + for term in terminations: + self.draw_fanin(term, cable) # WirelessLink - elif type(connector) is WirelessLink: - connector_labels = [ - f'Wireless link {connector}', - connector.get_status_display() - ] - if connector.ssid: - connector_labels.append(connector.ssid) - wirelesslink = self._draw_wirelesslink( - url=connector.get_absolute_url(), - labels=connector_labels - ) - connectors.append(wirelesslink) + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) - # Far end termination - termination = self._draw_box( - width=self.width * .8, - color=self._get_color(far_end), - url=far_end.get_absolute_url(), - labels=self._get_labels(far_end), - radius=5 - ) - terminations.append(termination) + # Far end termination(s) + if len(far_ends) > 1: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + self.draw_fanout(term, cable) + else: + self.draw_terminations(far_ends) # Far end parent - parent_object = self._draw_box( - width=self.width, - color=self._get_color(far_end.parent_object), - url=far_end.parent_object.get_absolute_url(), - labels=self._get_labels(far_end.parent_object), - y_indent=PADDING, - padding_multiplier=2 - ) - parent_objects.append(parent_object) + parent_objects = set(end.parent_object for end in far_ends) + self.draw_parent_objects(parent_objects) - elif far_end: + elif far_ends: # Attachment - attachment = self._draw_attachment() - connectors.append(attachment) + attachment = self.draw_attachment() + self.connectors.append(attachment) # ProviderNetwork - parent_object = self._draw_box( - width=self.width, - color=self._get_color(far_end), - url=far_end.get_absolute_url(), - labels=self._get_labels(far_end), - padding_multiplier=2 - ) - parent_objects.append(parent_object) + self.draw_parent_objects(set(end.parent_object for end in far_ends)) # Determine drawing size self.drawing = svgwrite.Drawing( @@ -330,7 +388,7 @@ class CableTraceSVG: self.drawing.defs.add(self.drawing.style(css_file.read())) # Add elements to the drawing in order of depth (Z axis) - for element in connectors + parent_objects + terminations: + for element in self.connectors + self.parent_objects + self.terminations: self.drawing.add(element) return self.drawing diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 4b062ad48..ff2a672ca 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -1,56 +1,109 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from django.utils.safestring import mark_safe from dcim.models import Cable from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT +from .template_code import CABLE_LENGTH __all__ = ( 'CableTable', ) +class CableTerminationsColumn(tables.Column): + """ + Args: + cable_end: Which side of the cable to report on (A or B) + attr: The CableTermination attribute to return for each instance (returns the termination object by default) + """ + def __init__(self, cable_end, attr='termination', *args, **kwargs): + self.cable_end = cable_end + self.attr = attr + super().__init__(accessor=Accessor('terminations'), *args, **kwargs) + + def _get_terminations(self, manager): + terminations = set() + for cabletermination in manager.all(): + if cabletermination.cable_end == self.cable_end: + if termination := getattr(cabletermination, self.attr, None): + terminations.add(termination) + + return terminations + + def render(self, value): + links = [ + f'{term}' for term in self._get_terminations(value) + ] + return mark_safe('
'.join(links) or '—') + + def value(self, value): + return ','.join([str(t) for t in self._get_terminations(value)]) + + # # Cables # class CableTable(NetBoxTable): - termination_a_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_a'), + a_terminations = CableTerminationsColumn( + cable_end='A', orderable=False, - verbose_name='Side A' - ) - rack_a = tables.Column( - accessor=Accessor('termination_a__device__rack'), - orderable=False, - linkify=True, - verbose_name='Rack A' - ) - termination_a = tables.Column( - accessor=Accessor('termination_a'), - orderable=False, - linkify=True, verbose_name='Termination A' ) - termination_b_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_b'), + b_terminations = CableTerminationsColumn( + cable_end='B', orderable=False, - verbose_name='Side B' + verbose_name='Termination B' ) - rack_b = tables.Column( - accessor=Accessor('termination_b__device__rack'), + device_a = CableTerminationsColumn( + cable_end='A', + attr='_device', + orderable=False, + verbose_name='Device A' + ) + device_b = CableTerminationsColumn( + cable_end='B', + attr='_device', + orderable=False, + verbose_name='Device B' + ) + location_a = CableTerminationsColumn( + cable_end='A', + attr='_location', + orderable=False, + verbose_name='Location A' + ) + location_b = CableTerminationsColumn( + cable_end='B', + attr='_location', + orderable=False, + verbose_name='Location B' + ) + rack_a = CableTerminationsColumn( + cable_end='A', + attr='_rack', + orderable=False, + verbose_name='Rack A' + ) + rack_b = CableTerminationsColumn( + cable_end='B', + attr='_rack', orderable=False, - linkify=True, verbose_name='Rack B' ) - termination_b = tables.Column( - accessor=Accessor('termination_b'), + site_a = CableTerminationsColumn( + cable_end='A', + attr='_site', orderable=False, - linkify=True, - verbose_name='Termination B' + verbose_name='Site A' + ) + site_b = CableTerminationsColumn( + cable_end='B', + attr='_site', + orderable=False, + verbose_name='Site B' ) status = columns.ChoiceFieldColumn() tenant = TenantColumn() @@ -66,10 +119,10 @@ class CableTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cable fields = ( - 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', + 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'color', 'length', 'tags', + 'created', 'last_updated', ) default_columns = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7124c2b1f..a07186973 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -13,15 +13,17 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ -CABLE_TERMINATION_PARENT = """ -{% if value.device %} - {{ value.device }} -{% elif value.circuit %} - {{ value.circuit }} -{% elif value.power_panel %} - {{ value.power_panel }} -{% endif %} -""" +# CABLE_TERMINATION_PARENT = """ +# {% with value.0 as termination %} +# {% if termination.device %} +# {{ termination.device }} +# {% elif termination.circuit %} +# {{ termination.circuit }} +# {% elif termination.power_panel %} +# {{ termination.power_panel }} +# {% endif %} +# {% endwith %} +# """ DEVICE_LINK = """ @@ -133,9 +135,9 @@ CONSOLEPORT_BUTTONS = """ {% else %} @@ -165,9 +167,9 @@ CONSOLESERVERPORT_BUTTONS = """ {% else %} @@ -197,8 +199,8 @@ POWERPORT_BUTTONS = """ {% else %} @@ -224,7 +226,7 @@ POWEROUTLET_BUTTONS = """ {% if not record.mark_connected %} - + {% else %} @@ -274,10 +276,10 @@ INTERFACE_BUTTONS = """ {% else %} @@ -313,12 +315,12 @@ FRONTPORT_BUTTONS = """ {% else %} @@ -350,12 +352,12 @@ REARPORT_BUTTONS = """ {% else %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 436f43b6f..1c6f45596 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -45,7 +45,7 @@ class Mixins: device=peer_device, name='Peer Termination' ) - cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') + cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1') cable.save() self.add_permissions(f'dcim.view_{self.model._meta.model_name}') @@ -55,9 +55,9 @@ class Mixins: self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], obj.name) + self.assertEqual(segment1[0][0]['name'], obj.name) self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], peer_obj.name) + self.assertEqual(segment1[2][0]['name'], peer_obj.name) class RegionTest(APIViewTestCases.APIViewTestCase): @@ -1884,33 +1884,33 @@ class CableTest(APIViewTestCases.APIViewTestCase): Interface.objects.bulk_create(interfaces) cables = ( - Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'), - Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'), - Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'), + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'), + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'), + Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'), ) for cable in cables: cable.save() cls.create_data = [ { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[4].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[14].pk, + 'a_terminations_type': 'dcim.interface', + 'a_terminations': [interfaces[4].pk], + 'b_terminations_type': 'dcim.interface', + 'b_terminations': [interfaces[14].pk], 'label': 'Cable 4', }, { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[5].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[15].pk, + 'a_terminations_type': 'dcim.interface', + 'a_terminations': [interfaces[5].pk], + 'b_terminations_type': 'dcim.interface', + 'b_terminations': [interfaces[15].pk], 'label': 'Cable 5', }, { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[6].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[16].pk, + 'a_terminations_type': 'dcim.interface', + 'a_terminations': [interfaces[6].pk], + 'b_terminations_type': 'dcim.interface', + 'b_terminations': [interfaces[16].pk], 'label': 'Cable 6', }, ] @@ -1936,7 +1936,7 @@ class ConnectedDeviceTest(APITestCase): self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected - cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) cable.save() @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 6849df012..cfbbbc63b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1,9 +1,9 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * from dcim.choices import LinkStatusChoices from dcim.models import * +from dcim.svg import CableTraceSVG from dcim.utils import object_to_path_node @@ -33,40 +33,24 @@ class CablePathTestCase(TestCase): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None): + def assertPathExists(self, nodes, **kwargs): """ Assert that a CablePath from origin to destination with a specific intermediate path exists. - :param origin: Originating endpoint - :param destination: Terminating endpoint, or None - :param path: Sequence of objects comprising the intermediate path (optional) + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) - :param msg: Custom failure message (optional) :return: The matching CablePath (if any) """ - kwargs = { - 'origin_type': ContentType.objects.get_for_model(origin), - 'origin_id': origin.pk, - } - if destination is not None: - kwargs['destination_type'] = ContentType.objects.get_for_model(destination) - kwargs['destination_id'] = destination.pk - else: - kwargs['destination_type__isnull'] = True - kwargs['destination_id__isnull'] = True - if path is not None: - kwargs['path'] = [object_to_path_node(obj) for obj in path] - if is_active is not None: - kwargs['is_active'] = is_active - if msg is None: - if destination is not None: - msg = f"Missing path from {origin} to {destination}" + path = [] + for step in nodes: + if type(step) in (list, tuple): + path.append([object_to_path_node(node) for node in step]) else: - msg = f"Missing partial path originating from {origin}" + path.append([object_to_path_node(step)]) - cablepath = CablePath.objects.filter(**kwargs).first() - self.assertIsNotNone(cablepath, msg=msg) + cablepath = CablePath.objects.filter(path=path, **kwargs).first() + self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath @@ -101,18 +85,20 @@ class CablePathTestCase(TestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=interface2) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[interface2] + ) cable1.save() + path1 = self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1,), + (interface1, cable1, interface2), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable1,), + (interface2, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -121,6 +107,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsSet(interface2, path2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() @@ -135,18 +124,20 @@ class CablePathTestCase(TestCase): consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Console Server Port 1') # Create cable 1 - cable1 = Cable(termination_a=consoleport1, termination_b=consoleserverport1) + cable1 = Cable( + a_terminations=[consoleport1], + b_terminations=[consoleserverport1] + ) cable1.save() + path1 = self.assertPathExists( - origin=consoleport1, - destination=consoleserverport1, - path=(cable1,), + (consoleport1, cable1, consoleserverport1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=consoleserverport1, - destination=consoleport1, - path=(cable1,), + (consoleserverport1, cable1, consoleport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -155,6 +146,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(consoleport1, path1) self.assertPathIsSet(consoleserverport1, path2) + # Test SVG generation + CableTraceSVG(consoleport1).render() + # Delete cable 1 cable1.delete() @@ -169,18 +163,20 @@ class CablePathTestCase(TestCase): poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Power Outlet 1') # Create cable 1 - cable1 = Cable(termination_a=powerport1, termination_b=poweroutlet1) + cable1 = Cable( + a_terminations=[powerport1], + b_terminations=[poweroutlet1] + ) cable1.save() + path1 = self.assertPathExists( - origin=powerport1, - destination=poweroutlet1, - path=(cable1,), + (powerport1, cable1, poweroutlet1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=poweroutlet1, - destination=powerport1, - path=(cable1,), + (poweroutlet1, cable1, powerport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -189,6 +185,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(powerport1, path1) self.assertPathIsSet(poweroutlet1, path2) + # Test SVG generation + CableTraceSVG(powerport1).render() + # Delete cable 1 cable1.delete() @@ -203,18 +202,20 @@ class CablePathTestCase(TestCase): powerfeed1 = PowerFeed.objects.create(power_panel=self.powerpanel, name='Power Feed 1') # Create cable 1 - cable1 = Cable(termination_a=powerport1, termination_b=powerfeed1) + cable1 = Cable( + a_terminations=[powerport1], + b_terminations=[powerfeed1] + ) cable1.save() + path1 = self.assertPathExists( - origin=powerport1, - destination=powerfeed1, - path=(cable1,), + (powerport1, cable1, powerfeed1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=powerfeed1, - destination=powerport1, - path=(cable1,), + (powerfeed1, cable1, powerport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -223,12 +224,118 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(powerport1, path1) self.assertPathIsSet(powerfeed1, path2) + # Test SVG generation + CableTraceSVG(powerport1).render() + # Delete cable 1 cable1.delete() # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) + def test_120_single_interface_to_multi_interface(self): + """ + [IF1] --C1-- [IF2] + [IF3] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[interface2, interface3] + ) + cable1.save() + + path1 = self.assertPathExists( + (interface1, cable1, (interface2, interface3)), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ((interface2, interface3), cable1, interface1), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + self.assertPathIsSet(interface3, path2) + + # Test SVG generation + CableTraceSVG(interface1).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + self.assertPathIsNotSet(interface1) + self.assertPathIsNotSet(interface2) + self.assertPathIsNotSet(interface3) + + def test_121_multi_interface_to_multi_interface(self): + """ + [IF1] --C1-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[interface3, interface4] + ) + cable1.save() + + path1 = self.assertPathExists( + ((interface1, interface2), cable1, (interface3, interface4)), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ((interface3, interface4), cable1, (interface1, interface2)), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsSet(interface3, path2) + self.assertPathIsSet(interface4, path2) + + # Test SVG generation + CableTraceSVG(interface1).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsNotSet(interface1) + self.assertPathIsNotSet(interface2) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] @@ -241,29 +348,31 @@ class CablePathTestCase(TestCase): ) # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1), - is_active=False + (interface1, cable1, frontport1, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 2 - cable2 = Cable(termination_a=rearport1, termination_b=interface2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -271,10 +380,8 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1), - is_active=False + (interface1, cable1, frontport1, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) interface1.refresh_from_db() @@ -282,7 +389,65 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsNotSet(interface2) - def test_202_multiple_paths_via_pass_through(self): + def test_202_single_path_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 2 + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface3, interface4] + ) + cable2.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ([interface3, interface4], cable2, rearport1, frontport1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + + def test_203_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] @@ -307,80 +472,71 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1_1, rearport1), - is_active=False + (interface1, cable1, frontport1_1, rearport1), + is_complete=False ) self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_2, rearport1), - is_active=False + (interface2, cable2, frontport1_2, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cable 3 - cable3 = Cable(termination_a=rearport1, termination_b=rearport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable3.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), - is_active=False + (interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), + is_complete=False ) self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), - is_active=False + (interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cables 4-5 - cable4 = Cable(termination_a=frontport2_1, termination_b=interface3) + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[interface3] + ) cable4.save() - cable5 = Cable(termination_a=frontport2_2, termination_b=interface4) + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[interface4] + ) cable5.save() path1 = self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, - cable4, - ), + (interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, interface3), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, - cable5, - ), + (interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, interface4), + is_complete=True, is_active=True ) path3 = self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, - cable1 - ), + (interface3, cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, interface1), + is_complete=True, is_active=True ) path4 = self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, - cable2 - ), + (interface4, cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, interface2), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -389,8 +545,8 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) interface1.refresh_from_db() interface2.refresh_from_db() interface3.refresh_from_db() @@ -400,7 +556,130 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) - def test_203_multiple_paths_via_nested_pass_throughs(self): + def test_204_multiple_paths_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF4] + [IF2] [IF5] + [IF3] --C2-- [FP1:2] [FP2:2] --C5-- [IF6] + [IF4] [IF7] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + interface5 = Interface.objects.create(device=self.device, name='Interface 5') + interface6 = Interface.objects.create(device=self.device, name='Interface 6') + interface7 = Interface.objects.create(device=self.device, name='Interface 7') + interface8 = Interface.objects.create(device=self.device, name='Interface 8') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[frontport1_1] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface3, interface4], + b_terminations=[frontport1_2] + ) + cable2.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1), + is_complete=False + ) + self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), + is_complete=False + ) + self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cables 4-5 + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[interface5, interface6] + ) + cable4.save() + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[interface7, interface8] + ) + cable5.save() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, [interface5, interface6]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, [interface7, interface8]), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + ([interface5, interface6], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + ([interface7, interface8], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + interface5.refresh_from_db() + interface6.refresh_from_db() + interface7.refresh_from_db() + interface8.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsSet(interface3, path2) + self.assertPathIsSet(interface4, path2) + self.assertPathIsSet(interface5, path3) + self.assertPathIsSet(interface6, path3) + self.assertPathIsSet(interface7, path4) + self.assertPathIsSet(interface8, path4) + + def test_205_multiple_paths_via_nested_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3] --C5-- [RP4] [FP4:1] --C6-- [IF3] [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] @@ -433,64 +712,77 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2, 6-7 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable6 = Cable(termination_a=interface3, termination_b=frontport4_1) + cable6 = Cable( + a_terminations=[interface3], + b_terminations=[frontport4_1] + ) cable6.save() - cable7 = Cable(termination_a=interface4, termination_b=frontport4_2) + cable7 = Cable( + a_terminations=[interface4], + b_terminations=[frontport4_2] + ) cable7.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3 and 5 - cable3 = Cable(termination_a=rearport1, termination_b=frontport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[frontport2] + ) cable3.save() - cable5 = Cable(termination_a=rearport4, termination_b=frontport3) + cable5 = Cable( + a_terminations=[rearport4], + b_terminations=[frontport3] + ) cable5.save() self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface # Create cable 4 - cable4 = Cable(termination_a=rearport2, termination_b=rearport3) + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[rearport3] + ) cable4.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3, cable5, rearport4, frontport4_1, - cable6 + ( + interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, cable4, rearport3, + frontport3, cable5, rearport4, frontport4_1, cable6, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3, cable5, rearport4, frontport4_2, - cable7 + ( + interface2, cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, cable4, rearport3, + frontport3, cable5, rearport4, frontport4_2, cable7, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable6, frontport4_1, rearport4, cable5, frontport3, rearport3, - cable4, rearport2, frontport2, cable3, rearport1, frontport1_1, - cable1 + ( + interface3, cable6, frontport4_1, rearport4, cable5, frontport3, rearport3, cable4, rearport2, + frontport2, cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable7, frontport4_2, rearport4, cable5, frontport3, rearport3, - cable4, rearport2, frontport2, cable3, rearport1, frontport1_2, - cable2 + ( + interface4, cable7, frontport4_2, rearport4, cable5, frontport3, rearport3, cable4, rearport2, + frontport2, cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -499,10 +791,10 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_204_multiple_paths_via_multiple_pass_throughs(self): + def test_206_multiple_paths_via_multiple_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] @@ -541,63 +833,83 @@ class CablePathTestCase(TestCase): ) # Create cables 1-3, 6-8 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable3 = Cable(termination_a=rearport1, termination_b=rearport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable3.save() - cable6 = Cable(termination_a=rearport3, termination_b=rearport4) + cable6 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) cable6.save() - cable7 = Cable(termination_a=interface3, termination_b=frontport4_1) + cable7 = Cable( + a_terminations=[interface3], + b_terminations=[frontport4_1] + ) cable7.save() - cable8 = Cable(termination_a=interface4, termination_b=frontport4_2) + cable8 = Cable( + a_terminations=[interface4], + b_terminations=[frontport4_2] + ) cable8.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 4 and 5 - cable4 = Cable(termination_a=frontport2_1, termination_b=frontport3_1) + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[frontport3_1] + ) cable4.save() - cable5 = Cable(termination_a=frontport2_2, termination_b=frontport3_2) + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[frontport3_2] + ) cable5.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, + ( + interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, frontport3_1, rearport3, cable6, rearport4, frontport4_1, - cable7 + cable7, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, + ( + interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, frontport3_2, rearport3, cable6, rearport4, frontport4_2, - cable8 + cable8, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1, + ( + interface3, cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1, cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, - cable1 + cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2, + ( + interface4, cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2, cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, - cable2 + cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -606,10 +918,10 @@ class CablePathTestCase(TestCase): cable5.delete() # Check for two complete paths (IF1 <--> IF2) and two partial (IF3 <--> IF4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 2) - def test_205_multiple_paths_via_patched_pass_throughs(self): + def test_207_multiple_paths_via_patched_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP3:2] --C6-- [IF4] @@ -638,55 +950,69 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2, 5-6 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable5 = Cable(termination_a=interface3, termination_b=frontport3_1) # IF3 -> FP3:1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3_1] + ) cable5.save() - cable6 = Cable(termination_a=interface4, termination_b=frontport3_2) # IF4 -> FP3:2 + cable6 = Cable( + a_terminations=[interface4], + b_terminations=[frontport3_2] + ) cable6.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3-4 - cable3 = Cable(termination_a=rearport1, termination_b=frontport2) # RP1 -> FP2 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[frontport2] + ) cable3.save() - cable4 = Cable(termination_a=rearport2, termination_b=rearport3) # RP2 -> RP3 + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[rearport3] + ) cable4.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3_1, cable5 + ( + interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_1, cable5, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3_2, cable6 + ( + interface2, cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_2, cable6, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable5, frontport3_1, rearport3, cable4, rearport2, frontport2, - cable3, rearport1, frontport1_1, cable1 + ( + interface3, cable5, frontport3_1, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable6, frontport3_2, rearport3, cable4, rearport2, frontport2, - cable3, rearport1, frontport1_2, cable2 + ( + interface4, cable6, frontport3_2, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -695,10 +1021,10 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_206_unidirectional_split_paths(self): + def test_208_unidirectional_split_paths(self): """ [IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2] [FP1:2] --C3-- [IF3] @@ -715,31 +1041,37 @@ class CablePathTestCase(TestCase): ) # Create cables 1 - cable1 = Cable(termination_a=interface1, termination_b=rearport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[rearport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, rearport1), - is_active=False + (interface1, cable1, rearport1), + is_complete=False, + is_split=True ) self.assertEqual(CablePath.objects.count(), 1) # Create cables 2-3 - cable2 = Cable(termination_a=interface2, termination_b=frontport1_1) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_1] + ) cable2.save() - cable3 = Cable(termination_a=interface3, termination_b=frontport1_2) + cable3 = Cable( + a_terminations=[interface3], + b_terminations=[frontport1_2] + ) cable3.save() self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, frontport1_1, rearport1, cable1), + (interface2, cable2, frontport1_1, rearport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=(cable3, frontport1_2, rearport1, cable1), + (interface3, cable3, frontport1_2, rearport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 3) @@ -749,20 +1081,16 @@ class CablePathTestCase(TestCase): # Check that the partial path was deleted and the two complete paths are now partial self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_1, rearport1), - is_active=False + (interface2, cable2, frontport1_1, rearport1), + is_complete=False ) self.assertPathExists( - origin=interface3, - destination=None, - path=(cable3, frontport1_2, rearport1), - is_active=False + (interface3, cable3, frontport1_2, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) - def test_207_rearport_without_frontport(self): + def test_209_rearport_without_frontport(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] """ @@ -774,19 +1102,23 @@ class CablePathTestCase(TestCase): ) # Create cables - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() - cable2 = Cable(termination_a=rearport1, termination_b=rearport2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1, cable2, rearport2), - is_active=False + (interface1, cable1, frontport1, rearport1, cable2, rearport2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) - def test_208_circuittermination(self): + def test_210_interface_to_circuittermination(self): """ [IF1] --C1-- [CT1] """ @@ -794,15 +1126,16 @@ class CablePathTestCase(TestCase): circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() # Check for incomplete path self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, circuittermination1), - is_active=False + (interface1, cable1, circuittermination1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -812,7 +1145,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_209_circuit_to_interface(self): + def test_211_interface_to_interface_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --C2-- [IF2] """ @@ -821,15 +1154,16 @@ class CablePathTestCase(TestCase): circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() # Check for partial path from interface1 self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, circuittermination1), - is_active=False + (interface1, cable1, circuittermination1), + is_complete=False ) # Create CT2 @@ -837,27 +1171,26 @@ class CablePathTestCase(TestCase): # Check for partial path to site self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) # Create cable 2 - cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[interface2] + ) cable2.save() # Check for complete path in each direction self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, circuittermination1, circuittermination2, cable2), + (interface1, cable1, circuittermination1, circuittermination2, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, circuittermination2, circuittermination1, cable1), + (interface2, cable2, circuittermination2, circuittermination1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -865,9 +1198,7 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -876,7 +1207,76 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsNotSet(interface2) - def test_210_circuit_to_site(self): + def test_212_interface_to_interface_via_circuit_with_breakouts(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[circuittermination1] + ) + cable1.save() + + # Check for partial path from interface1 + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1), + is_complete=False + ) + + # Create CT2 + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Check for partial path to site + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, self.site), + is_active=True + ) + + # Create cable 2 + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[interface3, interface4] + ) + cable2.save() + + # Check for complete path in each direction + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ([interface3, interface4], cable2, circuittermination2, circuittermination1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, self.site), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + + def test_213_interface_to_site_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --> [Site2] """ @@ -886,12 +1286,13 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=site2, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, site2), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -902,7 +1303,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_211_circuit_to_providernetwork(self): + def test_214_interface_to_providernetwork_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --> [PN1] """ @@ -912,12 +1313,13 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=providernetwork, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, providernetwork), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -928,7 +1330,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_212_multiple_paths_via_circuit(self): + def test_215_multiple_paths_via_circuit(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] @@ -955,52 +1357,66 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') # Create cables - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[circuittermination1] + ) cable3.save() - cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2 + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[circuittermination2] + ) cable4.save() - cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport2_1] + ) cable5.save() - cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2 + cable6 = Cable( + a_terminations=[interface4], + b_terminations=[frontport2_2] + ) cable6.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, - cable4, rearport2, frontport2_1, cable5 + ( + interface1, cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_1, cable5, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, - cable4, rearport2, frontport2_2, cable6 + ( + interface2, cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_2, cable6, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, - cable3, rearport1, frontport1_1, cable1 + ( + interface3, cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, - cable3, rearport1, frontport1_2, cable2 + ( + interface4, cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -1010,10 +1426,10 @@ class CablePathTestCase(TestCase): cable4.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_213_multiple_circuits_to_interface(self): + def test_216_interface_to_interface_via_multiple_circuits(self): """ [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2] """ @@ -1026,30 +1442,37 @@ class CablePathTestCase(TestCase): circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') # Create cables - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() - cable2 = Cable(termination_a=circuittermination2, termination_b=circuittermination3) + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[circuittermination3] + ) cable2.save() - cable3 = Cable(termination_a=circuittermination4, termination_b=interface2) + cable3 = Cable( + a_terminations=[circuittermination4], + b_terminations=[interface2] + ) cable3.save() # Check for paths self.assertPathExists( - origin=interface1, - destination=interface2, - path=( - cable1, circuittermination1, circuittermination2, cable2, circuittermination3, circuittermination4, - cable3 + ( + interface1, cable1, circuittermination1, circuittermination2, cable2, circuittermination3, + circuittermination4, cable3, interface2, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=( - cable3, circuittermination4, circuittermination3, cable2, circuittermination2, circuittermination1, - cable1 + ( + interface2, cable3, circuittermination4, circuittermination3, cable2, circuittermination2, + circuittermination1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1057,15 +1480,11 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=self.site, - path=(cable3, circuittermination4, circuittermination3), + (interface2, cable3, circuittermination4, circuittermination3, self.site), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1074,9 +1493,210 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsSet(interface2, path2) + def test_217_interface_to_interface_via_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP3] [FP3] --C3-- [IF2] + [FP2] [RP2] [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport2] + ) + cable1.save() + cable3 = Cable( + a_terminations=[interface2], + b_terminations=[frontport3, frontport4] + ) + cable3.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport2), (rearport1, rearport2)), + is_complete=False + ) + self.assertPathExists( + (interface2, cable3, (frontport3, frontport4), (rearport3, rearport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 2 + cable2 = Cable( + a_terminations=[rearport1, rearport2], + b_terminations=[rearport3, rearport4] + ) + cable2.save() + path1 = self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport2), (rearport1, rearport2), cable2, + (rearport3, rearport4), (frontport3, frontport4), cable3, interface2 + ), + is_complete=True + ) + path2 = self.assertPathExists( + ( + interface2, cable3, (frontport3, frontport4), (rearport3, rearport4), cable2, + (rearport1, rearport2), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + + # Check for two partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + + def test_218_interfaces_to_interfaces_via_multiposition_rear_ports(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] + [FP1:2] [FP2:2] + [IF2] --C2-- [FP1:3] [FP2:3] --C5-- [IF4] + [FP1:4] [FP2:4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport1_3 = FrontPort.objects.create( + device=self.device, name='Front Port 1:3', rear_port=rearport1, rear_port_position=3 + ) + frontport1_4 = FrontPort.objects.create( + device=self.device, name='Front Port 1:4', rear_port=rearport1, rear_port_position=4 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + frontport2_3 = FrontPort.objects.create( + device=self.device, name='Front Port 2:3', rear_port=rearport2, rear_port_position=3 + ) + frontport2_4 = FrontPort.objects.create( + device=self.device, name='Front Port 2:4', rear_port=rearport2, rear_port_position=4 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1, frontport1_2] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_3, frontport1_4] + ) + cable2.save() + self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1), + is_complete=False + ) + self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.save() + self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1, cable3, rearport2, (frontport2_1, frontport2_2)), + is_complete=False + ) + self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1, cable3, rearport2, (frontport2_3, frontport2_4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cables 4-5 + cable4 = Cable( + a_terminations=[frontport2_1, frontport2_2], + b_terminations=[interface3] + ) + cable4.save() + cable5 = Cable( + a_terminations=[frontport2_3, frontport2_4], + b_terminations=[interface4] + ) + cable5.save() + path1 = self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1, cable3, rearport2, (frontport2_1, frontport2_2), cable4, interface3), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1, cable3, rearport2, (frontport2_3, frontport2_4), cable5, interface4), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + (interface3, cable4, (frontport2_1, frontport2_2), rearport2, cable3, rearport1, (frontport1_1, frontport1_2), cable1, interface1), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + (interface4, cable5, (frontport2_3, frontport2_4), rearport2, cable3, rearport1, (frontport1_3, frontport1_4), cable2, interface2), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + self.assertPathIsSet(interface3, path3) + self.assertPathIsSet(interface4, path4) + def test_301_create_path_via_existing_cable(self): """ - [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') @@ -1090,34 +1710,39 @@ class CablePathTestCase(TestCase): ) # Create cable 2 - cable2 = Cable(termination_a=rearport1, termination_b=rearport2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable2.save() self.assertEqual(CablePath.objects.count(), 0) # Create cable1 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2), - is_active=False + (interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 3 - cable3 = Cable(termination_a=frontport2, termination_b=interface2) + cable3 = Cable( + a_terminations=[frontport2], + b_terminations=[interface2] + ) cable3.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3), + (interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1), + (interface2, cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1134,26 +1759,31 @@ class CablePathTestCase(TestCase): ) # Create cables 1 and 2 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() - cable2 = Cable(termination_a=rearport1, termination_b=interface2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface2] + ) cable2.save() self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2) self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" + cable2 = Cable.objects.get(pk=cable2.pk) # Rebuild object to ditch A/B terminations set earlier cable2.status = LinkStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=False ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -1163,15 +1793,13 @@ class CablePathTestCase(TestCase): cable2.status = LinkStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index b2fe2018b..24c45dd7f 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1950,8 +1950,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): ConsolePort.objects.bulk_create(console_ports) # Cables - Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save() - Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save() + Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save() + Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2097,8 +2097,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): ConsoleServerPort.objects.bulk_create(console_server_ports) # Cables - Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save() - Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save() + Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save() + Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2244,8 +2244,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): PowerPort.objects.bulk_create(power_ports) # Cables - Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save() - Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save() + Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save() + Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save() # Third port is not connected def test_name(self): @@ -2399,8 +2399,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): PowerOutlet.objects.bulk_create(power_outlets) # Cables - Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save() - Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save() + Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save() + Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2656,8 +2656,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Interface.objects.bulk_create(interfaces) # Cables - Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save() - Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save() + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save() # Third pair is not connected def test_name(self): @@ -2932,8 +2932,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPort.objects.bulk_create(front_ports) # Cables - Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save() - Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save() + Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save() + Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save() # Third port is not connected def test_name(self): @@ -3078,8 +3078,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): RearPort.objects.bulk_create(rear_ports) # Cables - Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save() - Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save() + Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save() + Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save() # Third port is not connected def test_name(self): @@ -3663,6 +3663,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', site=sites[0], slug='location-1'), + Location(name='Location 2', site=sites[1], slug='location-1'), + Location(name='Location 3', site=sites[2], slug='location-1'), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), + ) + Rack.objects.bulk_create(racks) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -3670,24 +3685,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): ) Tenant.objects.bulk_create(tenants) - racks = ( - Rack(name='Rack 1', site=sites[0]), - Rack(name='Rack 2', site=sites[1]), - Rack(name='Rack 3', site=sites[2]), - ) - Rack.objects.bulk_create(racks) - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1), - Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), - Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), - Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1), + Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2), + Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1), + Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2), ) Device.objects.bulk_create(devices) @@ -3711,13 +3719,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} @@ -3759,6 +3767,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'rack': [racks[0].name, racks[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'location': [locations[0].name, locations[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_site(self): site = Site.objects.all()[:2] params = {'site_id': [site[0].pk, site[1].pk]} @@ -3780,7 +3795,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_termination_ids(self): - interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3] + interface_ids = CableTermination.objects.filter( + cable__in=Cable.objects.all()[:3], + cable_end='A' + ).values_list('termination_id', flat=True) params = { 'termination_a_type': 'dcim.interface', 'termination_a_id': list(interface_ids), @@ -3924,8 +3942,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): PowerPort(device=device, name='Power Port 2'), ] PowerPort.objects.bulk_create(power_ports) - Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save() - Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save() + Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save() + Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save() def test_name(self): params = {'name': ['Power Feed 1', 'Power Feed 2']} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 98d57801d..d97823e7c 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -457,7 +457,7 @@ class CableTestCase(TestCase): self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface3 = Interface.objects.create(device=self.device2, name='eth1') - self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) self.cable.save() self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') @@ -493,12 +493,14 @@ class CableTestCase(TestCase): """ When a new Cable is created, it must be cached on either termination point. """ - interface1 = Interface.objects.get(pk=self.interface1.pk) - interface2 = Interface.objects.get(pk=self.interface2.pk) - self.assertEqual(self.cable.termination_a, interface1) - self.assertEqual(interface1._link_peer, interface2) - self.assertEqual(self.cable.termination_b, interface2) - self.assertEqual(interface2._link_peer, interface1) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertEqual(self.interface1.cable, self.cable) + self.assertEqual(self.interface2.cable, self.cable) + self.assertEqual(self.interface1.cable_end, 'A') + self.assertEqual(self.interface2.cable_end, 'B') + self.assertEqual(self.interface1.link_peers, [self.interface2]) + self.assertEqual(self.interface2.link_peers, [self.interface1]) def test_cable_deletion(self): """ @@ -510,50 +512,33 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) - self.assertIsNone(interface1._link_peer) + self.assertListEqual(interface1.link_peers, []) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) - self.assertIsNone(interface2._link_peer) + self.assertListEqual(interface2.link_peers, []) - def test_cabletermination_deletion(self): + def test_cable_validates_same_parent_object(self): """ - When a CableTermination object is deleted, its attached Cable (if any) must also be deleted. + The clean method should ensure that all terminations at either end of a Cable belong to the same parent object. """ - self.interface1.delete() - cable = Cable.objects.filter(pk=self.cable.pk).first() - self.assertIsNone(cable) + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_validates_same_type(self): + """ + The clean method should ensure that all terminations at either end of a Cable are of the same type. + """ + cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1]) + with self.assertRaises(ValidationError): + cable.clean() def test_cable_validates_compatible_types(self): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ - # An interface cannot be connected to a power port - cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_cannot_have_the_same_terminination_on_both_ends(self): - """ - A cable cannot be made with the same A and B side terminations - """ - cable = Cable(termination_a=self.interface1, termination_b=self.interface1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self): - """ - A cable cannot connect a front port to its corresponding rear port - """ - cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_cannot_terminate_to_an_existing_connection(self): - """ - Either side of a cable cannot be terminated when that side already has a connection - """ - # Try to create a cable with the same interface terminations - cable = Cable(termination_a=self.interface2, termination_b=self.interface1) + # An interface cannot be connected to a power port, for example + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) with self.assertRaises(ValidationError): cable.clean() @@ -561,45 +546,16 @@ class CableTestCase(TestCase): """ Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork """ - cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3]) with self.assertRaises(ValidationError): cable.clean() - def test_rearport_connections(self): - """ - Test various combinations of RearPort connections. - """ - # Connecting a single-position RearPort to a multi-position RearPort is ok - Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() - - # Connecting a single-position RearPort to an Interface is ok - Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean() - - # Connecting a single-position RearPort to a CircuitTermination is ok - Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() - - # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok - Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean() - - # Connecting a multi-position RearPort to an Interface is ok - Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean() - - # Connecting a multi-position RearPort to a CircuitTermination is ok - Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean() - - # Connecting a two-position RearPort to a three-position RearPort is NOT ok - with self.assertRaises( - ValidationError, - msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' - ): - Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean() - def test_cable_cannot_terminate_to_a_virtual_interface(self): """ A cable cannot terminate to a virtual interface """ virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) - cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) + cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface]) with self.assertRaises(ValidationError): cable.clean() @@ -608,6 +564,6 @@ class CableTestCase(TestCase): A cable cannot terminate to a wireless interface """ wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) - cable = Cable(termination_a=self.interface2, termination_b=wireless_interface) + cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 748bf24c8..c6a531a31 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1961,7 +1961,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=consoleport.device, name='Console Server Port 1' ) - Cable(termination_a=consoleport, termination_b=consoleserverport).save() + Cable(a_terminations=[consoleport], b_terminations=[consoleserverport]).save() response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk})) self.assertHttpStatus(response, 200) @@ -2017,7 +2017,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=consoleserverport.device, name='Console Port 1' ) - Cable(termination_a=consoleserverport, termination_b=consoleport).save() + Cable(a_terminations=[consoleserverport], b_terminations=[consoleport]).save() response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk})) self.assertHttpStatus(response, 200) @@ -2079,7 +2079,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=powerport.device, name='Power Outlet 1' ) - Cable(termination_a=powerport, termination_b=poweroutlet).save() + Cable(a_terminations=[powerport], b_terminations=[poweroutlet]).save() response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk})) self.assertHttpStatus(response, 200) @@ -2144,7 +2144,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): def test_trace(self): poweroutlet = PowerOutlet.objects.first() powerport = PowerPort.objects.first() - Cable(termination_a=poweroutlet, termination_b=powerport).save() + Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save() response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk})) self.assertHttpStatus(response, 200) @@ -2268,7 +2268,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): interface1, interface2 = Interface.objects.all()[:2] - Cable(termination_a=interface1, termination_b=interface2).save() + Cable(a_terminations=[interface1], b_terminations=[interface2]).save() response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) self.assertHttpStatus(response, 200) @@ -2339,7 +2339,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=frontport.device, name='Interface 1' ) - Cable(termination_a=frontport, termination_b=interface).save() + Cable(a_terminations=[frontport], b_terminations=[interface]).save() response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk})) self.assertHttpStatus(response, 200) @@ -2397,7 +2397,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=rearport.device, name='Interface 1' ) - Cable(termination_a=rearport, termination_b=interface).save() + Cable(a_terminations=[rearport], b_terminations=[interface]).save() response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk})) self.assertHttpStatus(response, 200) @@ -2630,19 +2630,18 @@ class CableTestCase( ) Interface.objects.bulk_create(interfaces) - Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save() tags = create_tags('Alpha', 'Bravo', 'Charlie') interface_ct = ContentType.objects.get_for_model(Interface) cls.form_data = { + # TODO: Revisit this limitation # Changing terminations not supported when editing an existing Cable - 'termination_a_type': interface_ct.pk, - 'termination_a_id': interfaces[0].pk, - 'termination_b_type': interface_ct.pk, - 'termination_b_id': interfaces[3].pk, + 'a_terminations': interfaces[0].pk, + 'b_terminations': interfaces[3].pk, 'type': CableTypeChoices.TYPE_CAT6, 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', @@ -2864,7 +2863,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): device=device, name='Power Port 1' ) - Cable(termination_a=powerfeed, termination_b=powerport).save() + Cable(a_terminations=[powerfeed], b_terminations=[powerport]).save() response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) self.assertHttpStatus(response, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c5cd0fa65..dbbd8707a 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -294,7 +294,6 @@ urlpatterns = [ path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), - path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -310,7 +309,6 @@ urlpatterns = [ path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -326,7 +324,6 @@ urlpatterns = [ path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), - path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -342,7 +339,6 @@ urlpatterns = [ path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -358,7 +354,6 @@ urlpatterns = [ path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -374,7 +369,6 @@ urlpatterns = [ path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -390,7 +384,6 @@ urlpatterns = [ path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Module bays @@ -447,6 +440,7 @@ urlpatterns = [ # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), + path('cables/add/', views.CableEditView.as_view(), name='cable_add'), path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), @@ -500,6 +494,5 @@ urlpatterns = [ path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), - path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index ec3a44603..26b6e2e25 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,5 @@ +import itertools + from django.contrib.contenttypes.models import ContentType from django.db import transaction @@ -29,27 +31,29 @@ def path_node_to_object(repr): return ct.model_class().objects.get(pk=object_id) -def create_cablepath(node): +def create_cablepath(terminations): """ - Create CablePaths for all paths originating from the specified node. + Create CablePaths for all paths originating from the specified set of nodes. + + :param terminations: Iterable of CableTermination objects """ from dcim.models import CablePath - cp = CablePath.from_origin(node) + cp = CablePath.from_origin(terminations) if cp: cp.save() -def rebuild_paths(obj): +def rebuild_paths(terminations): """ - Rebuild all CablePaths which traverse the specified node + Rebuild all CablePaths which traverse the specified nodes. """ from dcim.models import CablePath - cable_paths = CablePath.objects.filter(path__contains=obj) + for obj in terminations: + cable_paths = CablePath.objects.filter(_nodes__contains=obj) - with transaction.atomic(): - for cp in cable_paths: - cp.delete() - if cp.origin: - create_cablepath(cp.origin) + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + create_cablepath(cp.origins) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8e8ffbd82..1f2f04d7a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from django.views.generic import View -from circuits.models import Circuit +from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable @@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES from .models import * +CABLE_TERMINATION_TYPES = { + 'dcim.consoleport': ConsolePort, + 'dcim.consoleserverport': ConsoleServerPort, + 'dcim.powerport': PowerPort, + 'dcim.poweroutlet': PowerOutlet, + 'dcim.interface': Interface, + 'dcim.frontport': FrontPort, + 'dcim.rearport': RearPort, + 'dcim.powerfeed': PowerFeed, + 'circuits.circuittermination': CircuitTermination, +} + class DeviceComponentsView(generic.ObjectChildrenView): queryset = Device.objects.all() @@ -1717,7 +1729,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView): def get_extra_context(self, request, instance): interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( - '_path__destination' + '_path' ).exclude( type__in=NONCONNECTABLE_IFACE_TYPES ) @@ -2744,7 +2756,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView): # class CableListView(generic.ObjectListView): - queryset = Cable.objects.all() + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable @@ -2777,7 +2792,7 @@ class PathTraceView(generic.ObjectView): # Otherwise, find all CablePaths which traverse the specified object else: - related_paths = CablePath.objects.filter(path__contains=instance).prefetch_related('origin') + related_paths = CablePath.objects.filter(_nodes__contains=instance) # Check for specification of a particular path (when tracing pass-through ports) try: path_id = int(request.GET.get('cablepath_id')) @@ -2798,8 +2813,8 @@ class PathTraceView(generic.ObjectView): total_length, is_definitive = path.get_total_length() if path else (None, False) # Determine the path to the SVG trace image - api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace" - svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg" + api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" + svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" return { 'path': path, @@ -2810,77 +2825,38 @@ class PathTraceView(generic.ObjectView): } -class CableCreateView(generic.ObjectEditView): +class CableEditView(generic.ObjectEditView): queryset = Cable.objects.all() - template_name = 'dcim/cable_connect.html' + template_name = 'dcim/cable_edit.html' def dispatch(self, request, *args, **kwargs): - # Set the form class based on the type of component being connected - self.form = { - 'console-port': forms.ConnectCableToConsolePortForm, - 'console-server-port': forms.ConnectCableToConsoleServerPortForm, - 'power-port': forms.ConnectCableToPowerPortForm, - 'power-outlet': forms.ConnectCableToPowerOutletForm, - 'interface': forms.ConnectCableToInterfaceForm, - 'front-port': forms.ConnectCableToFrontPortForm, - 'rear-port': forms.ConnectCableToRearPortForm, - 'power-feed': forms.ConnectCableToPowerFeedForm, - 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, - }[kwargs.get('termination_b_type')] + # If creating a new Cable, initialize the form class using URL query params + if 'pk' not in kwargs: + self.form = forms.get_cable_form( + a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')), + b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type')) + ) return super().dispatch(request, *args, **kwargs) def get_object(self, **kwargs): - # Always return a new instance - return self.queryset.model() + """ + Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView + doesn't currently provide a hook for dynamic class resolution. + """ + obj = super().get_object(**kwargs) - def alter_object(self, obj, request, url_args, url_kwargs): - termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - termination_b_type_name = url_kwargs.get('termination_b_type') - self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) - - # Initialize Cable termination attributes - obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) - obj.termination_b_type = self.termination_b_type + if obj.pk: + # TODO: Optimize this logic + termination_a = obj.terminations.filter(cable_end='A').first() + a_type = termination_a.termination._meta.model if termination_a else None + termination_b = obj.terminations.filter(cable_end='B').first() + b_type = termination_b.termination._meta.model if termination_a else None + self.form = forms.get_cable_form(a_type, b_type) return obj - def get(self, request, *args, **kwargs): - obj = self.get_object(**kwargs) - obj = self.alter_object(obj, request, args, kwargs) - - # Parse initial data manually to avoid setting field values as lists - initial_data = {k: request.GET[k] for k in request.GET} - - # Set initial site and rack based on side A termination (if not already set) - termination_a_site = getattr(obj.termination_a.parent_object, 'site', None) - if termination_a_site and 'termination_b_region' not in initial_data: - initial_data['termination_b_region'] = termination_a_site.region - if termination_a_site and 'termination_b_site_group' not in initial_data: - initial_data['termination_b_site_group'] = termination_a_site.group - if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = termination_a_site - if 'termination_b_rack' not in initial_data: - initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None) - - form = self.form(instance=obj, initial=initial_data) - - return render(request, self.template_name, { - 'obj': obj, - 'obj_type': Cable._meta.verbose_name, - 'termination_b_type': self.termination_b_type.name, - 'form': form, - 'return_url': self.get_return_url(request, obj), - }) - - -class CableEditView(generic.ObjectEditView): - queryset = Cable.objects.all() - form = forms.CableForm - template_name = 'dcim/cable_edit.html' - class CableDeleteView(generic.ObjectDeleteView): queryset = Cable.objects.all() @@ -2893,14 +2869,14 @@ class CableBulkImportView(generic.BulkImportView): class CableBulkEditView(generic.BulkEditView): - queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + queryset = Cable.objects.prefetch_related('terminations') filterset = filtersets.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm class CableBulkDeleteView(generic.BulkDeleteView): - queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + queryset = Cable.objects.prefetch_related('terminations') filterset = filtersets.CableFilterSet table = tables.CableTable diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index cc768cbdc..5c4b2813d 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware: def process_exception(self, request, exception): # Handle exceptions that occur from REST API requests - if is_api_request(request): - return rest_api_server_error(request) + # if is_api_request(request): + # return rest_api_server_error(request) # Don't catch exceptions when in debug mode if settings.DEBUG: diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index f159ee637..666e3d28a 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -3,7 +3,6 @@ import sys from django.conf import settings from django.core.cache import cache -from django.db.models import F from django.http import HttpResponseServerError from django.shortcuts import redirect, render from django.template import loader @@ -37,14 +36,13 @@ class HomeView(View): return redirect("login") connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_complete=True ) connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_complete=True ) connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False, - pk__lt=F('_path__destination_id') + _path__is_complete=True ) def build_stats(): diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 50622f128..ff431f4ad 100644 Binary files a/netbox/project-static/dist/cable_trace.css and b/netbox/project-static/dist/cable_trace.css differ diff --git a/netbox/project-static/styles/cable-trace.scss b/netbox/project-static/styles/cable-trace.scss index 51d94d38a..59c67ad4d 100644 --- a/netbox/project-static/styles/cable-trace.scss +++ b/netbox/project-static/styles/cable-trace.scss @@ -55,7 +55,11 @@ svg { line { stroke-width: 5px; } - line.cable-shadow { + polyline { + fill: none; + stroke-width: 5px; + } + .cable-shadow { stroke: var(--nbx-trace-cable-shadow); stroke-width: 7px; } diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index f6bb377ec..f4e0ea6ca 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -44,16 +44,15 @@ Marked as connected {% elif termination.cable %} - {{ termination.cable }} - {% with peer=termination.get_link_peer %} - to + {{ termination.cable }} to + {% for peer in termination.link_peers %} {% if peer.device %} {{ peer.device|linkify }}
{% elif peer.circuit %} {{ peer.circuit|linkify }}
{% endif %} - {{ peer|linkify }} - {% endwith %} + {{ peer|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index cd171cbb3..f557792c1 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -5,85 +5,79 @@ {% load plugins %} {% block content %} -
-
-
-
- Cable -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Type{{ object.get_type_display|placeholder }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
Label{{ object.label|placeholder }}
Color - {% if object.color %} -   - {% else %} - {{ ''|placeholder }} - {% endif %} -
Length - {% if object.length %} - {{ object.length|floatformat }} {{ object.get_length_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
-
-
- Termination A -
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %} -
-
-
-
- Termination B -
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %} -
-
- {% plugin_right_page object %} +
+
+
+
Cable
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Type{{ object.get_type_display|placeholder }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
Label{{ object.label|placeholder }}
Color + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} +
Length + {% if object.length %} + {{ object.length|floatformat }} {{ object.get_length_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
-
-
- {% plugin_full_width_page object %} +
+
+
Termination A
+
+ {% include 'dcim/inc/cable_termination.html' with terminations=object.get_a_terminations %}
+
+
+
Termination B
+
+ {% include 'dcim/inc/cable_termination.html' with terminations=object.get_b_terminations %} +
+
+ {% plugin_right_page object %}
+
+
+
+ {% plugin_full_width_page object %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html deleted file mode 100644 index 1d50040c7..000000000 --- a/netbox/templates/dcim/cable_connect.html +++ /dev/null @@ -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 %} - -{% endblock %} - -{% block content-wrapper %} -
- {% with termination_a=form.instance.termination_a %} - {% render_errors form %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
-
-
A Side
-
- {% if termination_a.device %} - {# Device component #} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- {% else %} - {# Circuit termination #} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- {% endif %} -
-
-
-
- -
-
-
-
B Side
-
- {% if tabs %} - - {% 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 %} -
- -
- -
-
- {% render_field form.termination_b_id %} -
-
-
-
-
-
-
-
Cable
-
- {% include 'dcim/inc/cable_form.html' %} -
-
-
-
-
-
- Cancel - -
-
-
- {% endwith %} -
-{% endblock %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 45f05faad..e2cef7601 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,5 +1,125 @@ -{% extends 'generic/object_edit.html' %} +{% extends 'base/layout.html' %} +{% load static %} +{% load helpers %} +{% load form_helpers %} -{% block form %} - {% include 'dcim/inc/cable_form.html' %} +{% block title %}Connect Cable{% endblock %} + +{% block tabs %} + +{% endblock %} + +{% block content-wrapper %} +
+ {% render_errors form %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+
+
A Side
+
+ {% 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 %} +
+
+
+
+ +
+
+
+
B Side
+
+ {% 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 %} +
+
+
+
+
+
+
+
Cable
+
+ {% 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 %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+ {% render_field form.tags %} + {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+ {% endif %} +
+
+
+
+
+
+ Cancel + +
+
+
+
{% endblock %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ce2c1655d..f132a4ed8 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -111,28 +111,13 @@
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 52b1a3229..f4da080e8 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -113,28 +113,13 @@
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 891f217ee..4cea7989b 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -105,22 +105,22 @@
diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html deleted file mode 100644 index 0f11ac3cb..000000000 --- a/netbox/templates/dcim/inc/cable_form.html +++ /dev/null @@ -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 %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
-{% render_field form.tags %} -{% if form.custom_fields %} -
-
-
Custom Fields
-
- {% render_custom_fields form %} -
-{% endif %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 6d75aee85..9d1c43bdd 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -1,42 +1,58 @@ {% load helpers %} - {% if termination.device %} - {# Device component #} - - - - - - - - - {% if termination.device.rack %} - - - - - {% endif %} - - - - - - - - - {% else %} - {# Circuit termination #} - - - - - - - - - - - - - {% endif %} + {% if terminations.0.device %} + {# Device component #} + + + + + + + + + + + + + + + + + {% elif terminations.0.power_panel %} + {# Power feed #} + + + + + + + + + + + + + {% else %} + {# Circuit termination #} + + + + + + + + + {% endif %}
Device{{ termination.device|linkify }}
Site{{ termination.device.site|linkify }}
Rack{{ termination.device.rack|linkify }}
Type{{ termination|meta:"verbose_name"|capfirst }}
Component{{ termination|linkify }}
Provider{{ termination.circuit.provider|linkify }}
Circuit{{ termination.circuit|linkify }}
Termination{{ termination }}
Site{{ terminations.0.device.site|linkify }}
Rack{{ terminations.0.device.rack|linkify|placeholder }}
Device{{ terminations.0.device|linkify }}
{{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
Site{{ terminations.0.power_panel.site|linkify }}
Power Panel{{ terminations.0.power_panel|linkify }}
{{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
Provider{{ terminations.0.circuit.provider|linkify }}
Circuit + {% for term in terminations %} + {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %} + {% endfor %} +
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 247592e14..3a7fe986a 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -263,24 +263,16 @@
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index ed1f9a1cd..3972b30f3 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -158,8 +158,7 @@ {% if not object.mark_connected and not object.cable %}