mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-19 03:42:25 -06:00
#6454 fix merge conflicts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.api import WritableNestedSerializer
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCircuitSerializer',
|
||||
|
||||
@@ -2,12 +2,12 @@ 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.nested_serializers import NestedSiteSerializer
|
||||
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.fields import ChoiceField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
@@ -92,23 +92,22 @@ class CircuitSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
|
||||
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
|
||||
'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(ValidatedModelSerializer, 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', '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',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import NetBoxRouter
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
@@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
|
||||
fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
|
||||
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
|
||||
|
||||
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():
|
||||
|
||||
@@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
StaticSelect,
|
||||
)
|
||||
|
||||
@@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker()
|
||||
)
|
||||
termination_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker()
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
label='Commit rate (Kbps)'
|
||||
@@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Circuit
|
||||
fieldsets = (
|
||||
(None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
|
||||
('Circuit', ('provider', 'type', 'status', 'description')),
|
||||
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||
('Tenancy', ('tenant',)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'tenant', 'commit_rate', 'description', 'comments',
|
||||
|
||||
@@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
|
||||
'description', 'comments',
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
||||
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilterForm',
|
||||
@@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Provider', ('provider_id', 'provider_network_id')),
|
||||
('Attributes', ('type_id', 'status', 'commit_rate')),
|
||||
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
@@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker
|
||||
)
|
||||
termination_date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePicker
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=0,
|
||||
|
||||
@@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
|
||||
('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
|
||||
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'comments', 'tags',
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
'tenant_group', 'tenant', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
@@ -110,11 +111,12 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
widgets = {
|
||||
'status': StaticSelect(),
|
||||
'install_date': DatePicker(),
|
||||
'termination_date': DatePicker(),
|
||||
'commit_rate': SelectSpeedWidget(),
|
||||
}
|
||||
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
class CircuitTerminationForm(NetBoxModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
@@ -159,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
|
||||
__all__ = (
|
||||
@@ -10,7 +12,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CircuitTerminationType(ObjectType):
|
||||
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CircuitTermination
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import taggit.managers
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
16
netbox/circuits/migrations/0037_new_cabling_models.py
Normal file
16
netbox/circuits/migrations/0037_new_cabling_models.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuittermination',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
]
|
||||
20
netbox/circuits/migrations/0038_cabling_cleanup.py
Normal file
20
netbox/circuits/migrations/0038_cabling_cleanup.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0037_new_cabling_models'),
|
||||
('dcim', '0160_populate_cable_ends'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuittermination',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
]
|
||||
@@ -5,8 +5,10 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
from dcim.models import LinkTermination
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
|
||||
from dcim.models import CabledObjectModel
|
||||
from netbox.models import (
|
||||
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
|
||||
)
|
||||
from netbox.models.features import WebhooksMixin
|
||||
|
||||
__all__ = (
|
||||
@@ -79,7 +81,12 @@ class Circuit(NetBoxModel):
|
||||
install_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Date installed'
|
||||
verbose_name='Installed'
|
||||
)
|
||||
termination_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Terminates'
|
||||
)
|
||||
commit_rate = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
@@ -119,9 +126,9 @@ class Circuit(NetBoxModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
]
|
||||
clone_fields = (
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
@@ -141,7 +148,14 @@ class Circuit(NetBoxModel):
|
||||
return CircuitStatusChoices.colors.get(self.status)
|
||||
|
||||
|
||||
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
|
||||
class CircuitTermination(
|
||||
CustomFieldsMixin,
|
||||
CustomLinksMixin,
|
||||
TagsMixin,
|
||||
WebhooksMixin,
|
||||
ChangeLoggedModel,
|
||||
CabledObjectModel
|
||||
):
|
||||
circuit = models.ForeignKey(
|
||||
to='circuits.Circuit',
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -61,9 +61,9 @@ class Provider(NetBoxModel):
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
clone_fields = (
|
||||
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -68,8 +68,9 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z',
|
||||
'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||
|
||||
@@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
@@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'install_date': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_termination_date(self):
|
||||
params = {'termination_date': ['2021-01-01', '2021-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_commit_rate(self):
|
||||
params = {'commit_rate': ['1000', '2000']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -356,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'}
|
||||
|
||||
@@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
|
||||
'tenant': None,
|
||||
'install_date': datetime.date(2020, 1, 1),
|
||||
'termination_date': datetime.date(2021, 1, 1),
|
||||
'commit_rate': 1000,
|
||||
'description': 'A new circuit',
|
||||
'comments': 'Some comments',
|
||||
@@ -245,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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from dcim.views import CableCreateView, PathTraceView
|
||||
from dcim.views import PathTraceView
|
||||
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import *
|
||||
@@ -60,7 +60,6 @@ urlpatterns = [
|
||||
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
|
||||
|
||||
]
|
||||
|
||||
@@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=instance
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'tenant__group', 'terminations__site'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
|
||||
circuits_table.configure(request)
|
||||
@@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
Q(termination_a__provider_network=instance.pk) |
|
||||
Q(termination_z__provider_network=instance.pk)
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'tenant__group', 'terminations__site'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user)
|
||||
circuits_table.configure(request)
|
||||
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class CircuitListView(generic.ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
|
||||
'tenant__group', 'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
filterset_form = forms.CircuitFilterForm
|
||||
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
|
||||
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
|
||||
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
'termination_a__site', 'termination_z__site',
|
||||
'termination_a__provider_network', 'termination_z__provider_network',
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
@@ -7,12 +9,14 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.api.nested_serializers import (
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
|
||||
NestedVRFSerializer,
|
||||
)
|
||||
from ipam.models import ASN, VLAN
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import (
|
||||
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
)
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
@@ -26,58 +30,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_SERIALIZER_PREFIX)
|
||||
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_SERIALIZER_PREFIX)
|
||||
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_SERIALIZER_PREFIX)
|
||||
if endpoints := obj.connected_endpoints:
|
||||
serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
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
|
||||
|
||||
|
||||
#
|
||||
@@ -150,6 +164,7 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
|
||||
site = NestedSiteSerializer()
|
||||
parent = NestedLocationSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -157,8 +172,8 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
]
|
||||
|
||||
|
||||
@@ -202,7 +217,11 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
"""
|
||||
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
|
||||
"""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
id = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
read_only=True
|
||||
)
|
||||
name = serializers.CharField(read_only=True)
|
||||
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
@@ -247,7 +266,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
|
||||
)
|
||||
legend_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
|
||||
)
|
||||
margin_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
|
||||
)
|
||||
exclude = serializers.IntegerField(
|
||||
required=False,
|
||||
@@ -284,6 +306,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
u_height = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
label='Position (U)',
|
||||
min_value=decimal.Decimal(0.5),
|
||||
default=1.0
|
||||
)
|
||||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -440,12 +469,22 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
poe_mode = ChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
allow_blank=True
|
||||
)
|
||||
poe_type = ChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
allow_blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'created', 'last_updated',
|
||||
'poe_mode', 'poe_type', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -590,7 +629,14 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
|
||||
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
|
||||
position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
|
||||
position = serializers.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
allow_null=True,
|
||||
label='Position (U)',
|
||||
min_value=decimal.Decimal(0.5),
|
||||
default=None
|
||||
)
|
||||
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True)
|
||||
@@ -660,7 +706,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(
|
||||
@@ -677,18 +723,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(
|
||||
@@ -705,18 +751,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(
|
||||
@@ -737,21 +783,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(
|
||||
@@ -763,19 +806,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(
|
||||
@@ -790,6 +832,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
|
||||
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -798,7 +842,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
many=True
|
||||
)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
|
||||
wireless_link = NestedWirelessLinkSerializer(read_only=True)
|
||||
wireless_lans = SerializedPKRelatedField(
|
||||
queryset=WirelessLAN.objects.all(),
|
||||
@@ -814,10 +858,11 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
|
||||
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'mark_connected', 'cable', '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):
|
||||
@@ -834,7 +879,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(
|
||||
@@ -842,13 +887,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',
|
||||
]
|
||||
|
||||
@@ -864,7 +908,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(
|
||||
@@ -873,14 +917,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',
|
||||
]
|
||||
|
||||
|
||||
@@ -963,14 +1006,8 @@ 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 = GenericObjectSerializer(many=True, required=False)
|
||||
b_terminations = GenericObjectSerializer(many=True, required=False)
|
||||
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
|
||||
@@ -978,34 +1015,10 @@ 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', '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_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
data = serializer(termination, context=context).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.DictField)
|
||||
def get_termination_b(self, obj):
|
||||
return self._get_termination(obj, 'b')
|
||||
|
||||
|
||||
class TracedCableSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
@@ -1020,46 +1033,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_SERIALIZER_PREFIX)
|
||||
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_SERIALIZER_PREFIX)
|
||||
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_SERIALIZER_PREFIX)
|
||||
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_SERIALIZER_PREFIX)
|
||||
for nodes in obj.path_objects:
|
||||
serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(node, context=context).data)
|
||||
ret.append(serializer(nodes, context=context, many=True).data)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -1102,7 +1109,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(
|
||||
@@ -1126,13 +1133,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',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import NetBoxRouter
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -13,7 +12,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
|
||||
@@ -52,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')
|
||||
|
||||
for near_end, cable, far_end in obj.trace():
|
||||
if near_end is None:
|
||||
# Split paths
|
||||
# Serialize path objects, iterating over each three-tuple in the path
|
||||
for near_ends, cable, far_ends in obj.trace():
|
||||
if near_ends:
|
||||
serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
|
||||
else:
|
||||
# Path is split; stop here
|
||||
break
|
||||
if cable:
|
||||
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
|
||||
if far_ends:
|
||||
serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
|
||||
far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
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
|
||||
if far_end is not None:
|
||||
serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
z = serializer_b(far_end, context={'request': request}).data
|
||||
else:
|
||||
z = None
|
||||
|
||||
path.append((x, y, z))
|
||||
path.append((near_ends, cable, far_ends))
|
||||
|
||||
return Response(path)
|
||||
|
||||
@@ -95,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)
|
||||
@@ -216,6 +210,14 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
data = serializer.validated_data
|
||||
|
||||
if data['render'] == 'svg':
|
||||
# Determine attributes for highlighting devices (if any)
|
||||
highlight_params = []
|
||||
for param in request.GET.getlist('highlight'):
|
||||
try:
|
||||
highlight_params.append(param.split(':', 1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Render and return the elevation as an SVG drawing with the correct content type
|
||||
drawing = rack.get_elevation_svg(
|
||||
face=data['face'],
|
||||
@@ -224,7 +226,8 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
unit_height=data['unit_height'],
|
||||
legend_width=data['legend_width'],
|
||||
include_images=data['include_images'],
|
||||
base_url=request.build_absolute_uri('/')
|
||||
base_url=request.build_absolute_uri('/'),
|
||||
highlight_params=highlight_params
|
||||
)
|
||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||
|
||||
@@ -480,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
response = {m: None for m in napalm_methods}
|
||||
|
||||
config = get_config()
|
||||
username = config.NAPALM_USERNAME
|
||||
@@ -549,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
|
||||
@@ -558,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
|
||||
@@ -567,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
|
||||
@@ -576,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
|
||||
@@ -585,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
|
||||
@@ -595,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
|
||||
@@ -604,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
|
||||
@@ -649,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
|
||||
#
|
||||
@@ -690,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
|
||||
@@ -750,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)
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class DCIMConfig(AppConfig):
|
||||
name = "dcim"
|
||||
verbose_name = "DCIM"
|
||||
|
||||
def ready(self):
|
||||
|
||||
import dcim.signals
|
||||
from .models import CableTermination
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(CableTermination, '_device', {
|
||||
'_rack': 'rack',
|
||||
'_location': 'location',
|
||||
'_site': 'site',
|
||||
})
|
||||
denormalized.register(CableTermination, '_rack', {
|
||||
'_location': 'location',
|
||||
'_site': 'site',
|
||||
})
|
||||
denormalized.register(CableTermination, '_location', {
|
||||
'_site': 'site',
|
||||
})
|
||||
|
||||
@@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationStatusChoices(ChoiceSet):
|
||||
key = 'Location.status'
|
||||
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGING = 'staging'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
STATUS_RETIRED = 'retired'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_STAGING, 'Staging', 'blue'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||
(STATUS_RETIRED, 'Retired', 'red'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
@@ -1031,6 +1053,51 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class InterfacePoEModeChoices(ChoiceSet):
|
||||
|
||||
MODE_PD = 'pd'
|
||||
MODE_PSE = 'pse'
|
||||
|
||||
CHOICES = (
|
||||
(MODE_PD, 'PD'),
|
||||
(MODE_PSE, 'PSE'),
|
||||
)
|
||||
|
||||
|
||||
class InterfacePoETypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||
|
||||
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
|
||||
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
|
||||
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
|
||||
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
'IEEE Standard',
|
||||
(
|
||||
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Passive',
|
||||
(
|
||||
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
|
||||
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# FrontPorts/RearPorts
|
||||
#
|
||||
@@ -1243,6 +1310,22 @@ class CableLengthUnitChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# CableTerminations
|
||||
#
|
||||
|
||||
class CableEndChoices(ChoiceSet):
|
||||
|
||||
SIDE_A = 'A'
|
||||
SIDE_B = 'B'
|
||||
|
||||
CHOICES = (
|
||||
(SIDE_A, 'A'),
|
||||
(SIDE_B, 'B'),
|
||||
# ('', ''),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# PowerFeeds
|
||||
#
|
||||
|
||||
@@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
|
||||
RACK_U_HEIGHT_DEFAULT = 42
|
||||
|
||||
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
||||
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
||||
|
||||
|
||||
#
|
||||
@@ -84,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=(
|
||||
|
||||
@@ -21,6 +21,7 @@ from .models import *
|
||||
|
||||
__all__ = (
|
||||
'CableFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
@@ -216,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
|
||||
to_field_name='slug',
|
||||
label='Location (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=LocationStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
fields = ['id', 'name', 'slug', 'status', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -647,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
poe_mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoEModeChoices
|
||||
)
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@@ -1116,7 +1127,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(django_filters.FilterSet):
|
||||
class CabledObjectFilterSet(django_filters.FilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
@@ -1139,7 +1150,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
class ConsolePortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1149,13 +1160,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(
|
||||
@@ -1165,13 +1176,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(
|
||||
@@ -1181,13 +1192,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(
|
||||
@@ -1201,13 +1212,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
|
||||
@@ -1247,6 +1258,12 @@ class InterfaceFilterSet(
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter()
|
||||
wwn = MultiValueWWNFilter()
|
||||
poe_mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoEModeChoices
|
||||
)
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label='Assigned VLAN'
|
||||
@@ -1280,8 +1297,8 @@ class InterfaceFilterSet(
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
||||
]
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
@@ -1335,7 +1352,7 @@ class InterfaceFilterSet(
|
||||
class FrontPortFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CableTerminationFilterSet
|
||||
CabledObjectFilterSet
|
||||
):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
@@ -1344,13 +1361,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,
|
||||
@@ -1359,7 +1376,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):
|
||||
@@ -1507,10 +1524,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
|
||||
)
|
||||
@@ -1521,44 +1546,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):
|
||||
@@ -1618,7 +1656,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',
|
||||
@@ -1672,7 +1710,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():
|
||||
|
||||
@@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(
|
||||
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
|
||||
form_from_model(Interface, [
|
||||
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
|
||||
]),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = Interface
|
||||
field_order = (
|
||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
|
||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
|
||||
'poe_type', 'mark_connected', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(LocationStatusChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
@@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Location
|
||||
fieldsets = (
|
||||
(None, ('site', 'parent', 'tenant', 'description')),
|
||||
(None, ('site', 'parent', 'status', 'tenant', 'description')),
|
||||
)
|
||||
nullable_fields = ('parent', 'tenant', 'description')
|
||||
|
||||
@@ -812,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
|
||||
description = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE type'
|
||||
)
|
||||
|
||||
nullable_fields = ('label', 'description')
|
||||
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
|
||||
|
||||
|
||||
class FrontPortTemplateBulkEditForm(BulkEditForm):
|
||||
@@ -1063,6 +1083,20 @@ class InterfaceBulkEditForm(
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
label='Management only'
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
label='PoE type'
|
||||
)
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
@@ -1105,14 +1139,15 @@ class InterfaceBulkEditForm(
|
||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
||||
'tagged_vlans', 'vrf',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
||||
'invalid_choice': 'Location not found.',
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=LocationStatusChoices,
|
||||
help_text='Operational status'
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
@@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
class RackRoleCSVForm(NetBoxModelCSVForm):
|
||||
@@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
||||
choices=InterfaceDuplexChoices,
|
||||
required=False
|
||||
)
|
||||
poe_mode = CSVChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
help_text='PoE mode'
|
||||
)
|
||||
poe_type = CSVChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
help_text='PoE type'
|
||||
)
|
||||
mode = CSVChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
@@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
|
||||
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power',
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -941,7 +955,7 @@ class CableCSVForm(NetBoxModelCSVForm):
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
setattr(self.instance, f'termination_{side}', termination_object)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
def clean_side_a_name(self):
|
||||
|
||||
@@ -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',
|
||||
'kind': 'physical', # Exclude virtual interfaces
|
||||
}
|
||||
)
|
||||
|
||||
# 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.a_terminations
|
||||
self.initial['b_terminations'] = self.instance.b_terminations
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
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',
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
@@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
model = Location
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
|
||||
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
@@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
},
|
||||
label=_('Parent')
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=LocationStatusChoices,
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -739,7 +743,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')),
|
||||
)
|
||||
@@ -756,13 +760,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(
|
||||
@@ -770,8 +784,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')
|
||||
)
|
||||
@@ -982,6 +997,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
||||
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||
)
|
||||
@@ -1022,6 +1038,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label='WWN'
|
||||
)
|
||||
poe_mode = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
rf_role = MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
|
||||
@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Location', (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
@@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
||||
'tags',
|
||||
)
|
||||
widgets = {
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class RackRoleForm(NetBoxModelForm):
|
||||
@@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
position = forms.IntegerField(
|
||||
position = forms.DecimalField(
|
||||
required=False,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
@@ -1048,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'poe_mode': StaticSelect(),
|
||||
'poe_type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1329,6 +1335,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', (
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
@@ -1339,14 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf', 'tags',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'speed': SelectSpeedWidget(),
|
||||
'poe_mode': StaticSelect(),
|
||||
'poe_type': StaticSelect(),
|
||||
'duplex': StaticSelect(),
|
||||
'mode': StaticSelect(),
|
||||
'rf_role': StaticSelect(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
|
||||
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
|
||||
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
|
||||
from dcim.models import *
|
||||
from utilities.forms import BootstrapMixin
|
||||
|
||||
@@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=InterfaceTypeChoices.CHOICES
|
||||
)
|
||||
poe_mode = forms.ChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = forms.ChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
|
||||
]
|
||||
|
||||
|
||||
|
||||
5
netbox/dcim/graphql/mixins.py
Normal file
5
netbox/dcim/graphql/mixins.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class CabledObjectMixin:
|
||||
|
||||
def resolve_cable_end(self, info):
|
||||
# Handle empty values
|
||||
return self.cable_end or None
|
||||
@@ -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,13 +228,19 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
exclude = ('_path',)
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
|
||||
def resolve_poe_mode(self, info):
|
||||
return self.poe_mode or None
|
||||
|
||||
def resolve_poe_type(self, info):
|
||||
return self.poe_type or None
|
||||
|
||||
def resolve_mode(self, info):
|
||||
return self.mode or None
|
||||
|
||||
@@ -243,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.InterfaceTemplateFilterSet
|
||||
|
||||
def resolve_poe_mode(self, info):
|
||||
return self.poe_mode or None
|
||||
|
||||
def resolve_poe_type(self, info):
|
||||
return self.poe_type or None
|
||||
|
||||
|
||||
class InventoryItemType(ComponentObjectType):
|
||||
|
||||
@@ -316,7 +337,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@@ -324,7 +345,7 @@ class PowerFeedType(NetBoxObjectType):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@@ -360,7 +381,7 @@ class PowerPanelType(NetBoxObjectType):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
@@ -412,7 +433,7 @@ class RackRoleType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.RackRoleFilterSet
|
||||
|
||||
|
||||
class RearPortType(ComponentObjectType):
|
||||
class RearPortType(ComponentObjectType, CabledObjectMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.RearPort
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0153_created_datetimefield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='u_height',
|
||||
field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='position',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
|
||||
),
|
||||
]
|
||||
33
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
33
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-22 00:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0154_half_height_rack_units'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='poe_mode',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='poe_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='poe_mode',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='poe_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
]
|
||||
18
netbox/dcim/migrations/0156_location_status.py
Normal file
18
netbox/dcim/migrations/0156_location_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-22 17:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0155_interface_poe_mode_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='location',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
||||
95
netbox/dcim/migrations/0157_new_cabling_models.py
Normal file
95
netbox/dcim/migrations/0157_new_cabling_models.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0156_location_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
# Create CableTermination model
|
||||
migrations.CreateModel(
|
||||
name='CableTermination',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('cable_end', models.CharField(max_length=1)),
|
||||
('termination_id', models.PositiveBigIntegerField()),
|
||||
('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
|
||||
('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
|
||||
('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
|
||||
('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
|
||||
('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
|
||||
('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('cable', 'cable_end', 'pk'),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='cabletermination',
|
||||
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
|
||||
),
|
||||
|
||||
# Update CablePath model
|
||||
migrations.RenameField(
|
||||
model_name='cablepath',
|
||||
old_name='path',
|
||||
new_name='_nodes',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cablepath',
|
||||
name='path',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cablepath',
|
||||
name='is_complete',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
|
||||
# Add cable_end field to cable termination models
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='cable_end',
|
||||
field=models.CharField(blank=True, max_length=1),
|
||||
),
|
||||
]
|
||||
87
netbox/dcim/migrations/0158_populate_cable_terminations.py
Normal file
87
netbox/dcim/migrations/0158_populate_cable_terminations.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import sys
|
||||
|
||||
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 = []
|
||||
cable_count = cables.count()
|
||||
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)
|
||||
))
|
||||
|
||||
# Output progress occasionally
|
||||
if 'test' not in sys.argv and not i % 100:
|
||||
progress = float(i) * 100 / cable_count
|
||||
if i == 100:
|
||||
print('')
|
||||
sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)")
|
||||
sys.stdout.flush()
|
||||
|
||||
# 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
|
||||
),
|
||||
]
|
||||
50
netbox/dcim/migrations/0159_populate_cable_paths.py
Normal file
50
netbox/dcim/migrations/0159_populate_cable_paths.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations
|
||||
|
||||
from dcim.utils import compile_path_node
|
||||
|
||||
|
||||
def populate_cable_paths(apps, schema_editor):
|
||||
"""
|
||||
Replicate terminations from the Cable model into CableTermination instances.
|
||||
"""
|
||||
CablePath = apps.get_model('dcim', 'CablePath')
|
||||
|
||||
# Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
|
||||
cable_paths = []
|
||||
for cablepath in CablePath.objects.all():
|
||||
|
||||
# Origin
|
||||
origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
|
||||
cablepath.path.append([origin])
|
||||
cablepath._nodes.insert(0, origin)
|
||||
|
||||
# Transit nodes
|
||||
cablepath.path.extend([
|
||||
[node] for node in cablepath._nodes[1:]
|
||||
])
|
||||
|
||||
# Destination
|
||||
if cablepath.destination_id:
|
||||
destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
|
||||
cablepath.path.append([destination])
|
||||
cablepath._nodes.append(destination)
|
||||
cablepath.is_complete = True
|
||||
|
||||
cable_paths.append(cablepath)
|
||||
|
||||
# Bulk update all CableTerminations
|
||||
CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0158_populate_cable_terminations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=populate_cable_paths,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
46
netbox/dcim/migrations/0160_populate_cable_ends.py
Normal file
46
netbox/dcim/migrations/0160_populate_cable_ends.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_cable_terminations(apps, schema_editor):
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
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:
|
||||
model.objects.filter(
|
||||
id__in=Cable.objects.filter(
|
||||
termination_a_type__app_label=model._meta.app_label,
|
||||
termination_a_type__model=model._meta.model_name
|
||||
).values_list('termination_a_id', flat=True)
|
||||
).update(cable_end='A')
|
||||
model.objects.filter(
|
||||
id__in=Cable.objects.filter(
|
||||
termination_b_type__app_label=model._meta.app_label,
|
||||
termination_b_type__model=model._meta.model_name
|
||||
).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
|
||||
),
|
||||
]
|
||||
134
netbox/dcim/migrations/0161_cabling_cleanup.py
Normal file
134
netbox/dcim/migrations/0161_cabling_cleanup.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0160_populate_cable_ends'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
# Remove old fields from Cable
|
||||
migrations.AlterModelOptions(
|
||||
name='cable',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='cable',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_a_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_a_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_b_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='termination_b_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='_termination_a_device',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cable',
|
||||
name='_termination_b_device',
|
||||
),
|
||||
|
||||
# Remove old fields from CablePath
|
||||
migrations.AlterUniqueTogether(
|
||||
name='cablepath',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='destination_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='destination_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='origin_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cablepath',
|
||||
name='origin_type',
|
||||
),
|
||||
|
||||
# Remove link peer type/ID fields from cable termination models
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleserverport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='consoleserverport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='frontport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='frontport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerfeed',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='poweroutlet',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='poweroutlet',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='powerport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='rearport',
|
||||
name='_link_peer_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='rearport',
|
||||
name='_link_peer_type',
|
||||
),
|
||||
|
||||
]
|
||||
@@ -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,13 @@ 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)
|
||||
self._terminations_modified = False
|
||||
|
||||
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 or retrieve A/B terminations
|
||||
if a_terminations:
|
||||
self.a_terminations = a_terminations
|
||||
if b_terminations:
|
||||
self.b_terminations = b_terminations
|
||||
|
||||
def __str__(self):
|
||||
pk = self.pk or self._pk
|
||||
@@ -150,124 +108,68 @@ class Cable(NetBoxModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
@property
|
||||
def a_terminations(self):
|
||||
if hasattr(self, '_a_terminations'):
|
||||
return self._a_terminations
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
|
||||
]
|
||||
|
||||
@a_terminations.setter
|
||||
def a_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
self._a_terminations = value
|
||||
|
||||
@property
|
||||
def b_terminations(self):
|
||||
if hasattr(self, '_b_terminations'):
|
||||
return self._b_terminations
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
|
||||
]
|
||||
|
||||
@b_terminations.setter
|
||||
def b_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
self._b_terminations = value
|
||||
|
||||
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 = ''
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
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 termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
for termination in self.b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).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 +177,447 @@ 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 self._terminations_modified:
|
||||
for termination, ct in a_terminations.items():
|
||||
if termination.pk and termination not in self.a_terminations:
|
||||
ct.delete()
|
||||
for termination, ct in b_terminations.items():
|
||||
if termination.pk and termination not in self.b_terminations:
|
||||
ct.delete()
|
||||
|
||||
# Save new CableTerminations (if any)
|
||||
if self._terminations_modified:
|
||||
for termination in self.a_terminations:
|
||||
if not termination.pk or termination not in a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).save()
|
||||
for termination in self.b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
|
||||
def get_status_color(self):
|
||||
return LinkStatusChoices.colors.get(self.status)
|
||||
|
||||
def get_compatible_types(self):
|
||||
|
||||
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.
|
||||
"""
|
||||
return self.path_objects[0]
|
||||
|
||||
@property
|
||||
def destinations(self):
|
||||
"""
|
||||
Return the list of destination objects, if the path is complete.
|
||||
"""
|
||||
if not self.is_complete:
|
||||
return []
|
||||
return self.path_objects[-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:
|
||||
if not terminations:
|
||||
return None
|
||||
|
||||
destination = None
|
||||
# Ensure all originating terminations are attached to the same link
|
||||
if len(terminations) > 1:
|
||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
||||
|
||||
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:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# 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
|
||||
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 not remote_terminations:
|
||||
break
|
||||
|
||||
# Determine the peer FrontPort's position
|
||||
if peer_termination.positions == 1:
|
||||
position = 1
|
||||
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])
|
||||
|
||||
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 +634,19 @@ 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)
|
||||
try:
|
||||
nodes.append(prefetched[ct_id][object_id])
|
||||
except KeyError:
|
||||
# Ignore stale (deleted) object IDs
|
||||
pass
|
||||
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 +654,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 +677,6 @@ class CablePath(models.Model):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
rearport = path_node_to_object(self.path[-1])
|
||||
rearports = self.path_objects[-1]
|
||||
|
||||
return FrontPort.objects.filter(rear_port=rearport)
|
||||
return FrontPort.objects.filter(rear_port__in=rearports)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -357,6 +357,18 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
default=False,
|
||||
verbose_name='Management only'
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoEModeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE mode'
|
||||
)
|
||||
poe_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoETypeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE type'
|
||||
)
|
||||
|
||||
component_model = Interface
|
||||
|
||||
@@ -373,6 +385,8 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
type=self.type,
|
||||
mgmt_only=self.mgmt_only,
|
||||
poe_mode=self.poe_mode,
|
||||
poe_type=self.poe_type,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -383,6 +397,8 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
'mgmt_only': self.mgmt_only,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
'poe_mode': self.poe_mode,
|
||||
'poe_type': self.poe_type,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -102,14 +103,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',
|
||||
@@ -118,36 +115,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:
|
||||
@@ -156,22 +138,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):
|
||||
"""
|
||||
@@ -179,10 +158,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
|
||||
@@ -205,50 +205,48 @@ 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:
|
||||
# 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)
|
||||
path.extend(origin._path.path_objects)
|
||||
|
||||
# Check for bridge interface to continue the trace
|
||||
origin = getattr(origin._path.destination, 'bridge', None)
|
||||
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
|
||||
if len(path) % 3 == 1:
|
||||
path.extend(([], []))
|
||||
# If the path ends at a site or provider network, inject a null "link" to render an attachment
|
||||
elif len(path) % 3 == 2:
|
||||
path.insert(-1, [])
|
||||
|
||||
# Return the path as a list of three-tuples (A termination, cable, B termination)
|
||||
# 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(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.
|
||||
"""
|
||||
@@ -265,7 +263,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
help_text='Port speed in bits per second'
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -275,7 +273,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.
|
||||
"""
|
||||
@@ -292,7 +290,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
help_text='Port speed in bits per second'
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -306,7 +304,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.
|
||||
"""
|
||||
@@ -329,7 +327,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
help_text="Allocated power draw (watts)"
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'maximum_draw', 'allocated_draw']
|
||||
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -347,36 +345,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'),
|
||||
)
|
||||
@@ -384,7 +403,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
|
||||
@@ -393,12 +412,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.
|
||||
"""
|
||||
@@ -422,7 +441,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type', 'power_port', 'feed_leg']
|
||||
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -436,9 +455,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")
|
||||
|
||||
|
||||
#
|
||||
@@ -512,7 +529,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.
|
||||
"""
|
||||
@@ -589,6 +606,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
validators=(MaxValueValidator(127),),
|
||||
verbose_name='Transmit power (dBm)'
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoEModeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE mode'
|
||||
)
|
||||
poe_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfacePoETypeChoices,
|
||||
blank=True,
|
||||
verbose_name='PoE type'
|
||||
)
|
||||
wireless_link = models.ForeignKey(
|
||||
to='wireless.WirelessLink',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -636,8 +665,17 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
object_id_field='interface_id',
|
||||
related_query_name='+'
|
||||
)
|
||||
l2vpn_terminations = GenericRelation(
|
||||
to='ipam.L2VPNTermination',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface',
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
|
||||
clone_fields = (
|
||||
'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
@@ -725,6 +763,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
f"of virtual chassis {self.device.virtual_chassis}."
|
||||
})
|
||||
|
||||
# PoE validation
|
||||
|
||||
# Only physical interfaces may have a PoE mode/type assigned
|
||||
if self.poe_mode and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_mode': "Virtual interfaces cannot have a PoE mode."
|
||||
})
|
||||
if self.poe_type and self.is_virtual:
|
||||
raise ValidationError({
|
||||
'poe_type': "Virtual interfaces cannot have a PoE type."
|
||||
})
|
||||
|
||||
# An interface with a PoE type set must also specify a mode
|
||||
if self.poe_type and not self.poe_mode:
|
||||
raise ValidationError({
|
||||
'poe_type': "Must specify PoE mode when designating a PoE type."
|
||||
})
|
||||
|
||||
# Wireless validation
|
||||
|
||||
# RF role & channel may only be set for wireless interfaces
|
||||
@@ -792,12 +848,28 @@ 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()
|
||||
|
||||
|
||||
#
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
class FrontPort(ModularComponentModel, LinkTermination):
|
||||
class FrontPort(ModularComponentModel, CabledObjectModel):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
"""
|
||||
@@ -821,7 +893,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
|
||||
]
|
||||
)
|
||||
|
||||
clone_fields = ['device', 'type']
|
||||
clone_fields = ('device', 'type', 'color')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -850,7 +922,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
|
||||
})
|
||||
|
||||
|
||||
class RearPort(ModularComponentModel, LinkTermination):
|
||||
class RearPort(ModularComponentModel, CabledObjectModel):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
"""
|
||||
@@ -868,7 +940,7 @@ class RearPort(ModularComponentModel, LinkTermination):
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
clone_fields = ['device', 'type', 'positions']
|
||||
clone_fields = ('device', 'type', 'color', 'positions')
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -903,7 +975,7 @@ class ModuleBay(ComponentModel):
|
||||
help_text='Identifier to reference when renaming installed components'
|
||||
)
|
||||
|
||||
clone_fields = ['device']
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -925,7 +997,7 @@ class DeviceBay(ComponentModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
clone_fields = ['device']
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -1062,7 +1134,7 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id']
|
||||
clone_fields = ('device', 'parent', 'role', 'manufacturer', 'part_id',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('device__id', 'parent__id', '_name')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
import yaml
|
||||
|
||||
from django.apps import apps
|
||||
@@ -99,8 +101,10 @@ class DeviceType(NetBoxModel):
|
||||
blank=True,
|
||||
help_text='Discrete part number (optional)'
|
||||
)
|
||||
u_height = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
u_height = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
default=1.0,
|
||||
verbose_name='Height (U)'
|
||||
)
|
||||
is_full_depth = models.BooleanField(
|
||||
@@ -133,9 +137,9 @@ class DeviceType(NetBoxModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'model']
|
||||
@@ -170,7 +174,7 @@ class DeviceType(NetBoxModel):
|
||||
'model': self.model,
|
||||
'slug': self.slug,
|
||||
'part_number': self.part_number,
|
||||
'u_height': self.u_height,
|
||||
'u_height': float(self.u_height),
|
||||
'is_full_depth': self.is_full_depth,
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
@@ -220,6 +224,12 @@ class DeviceType(NetBoxModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# U height must be divisible by 0.5
|
||||
if self.u_height % decimal.Decimal(0.5):
|
||||
raise ValidationError({
|
||||
'u_height': "U height must be in increments of 0.5 rack units."
|
||||
})
|
||||
|
||||
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
|
||||
# room to expand within their racks. This validation will impose a very high performance penalty when there are
|
||||
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
|
||||
@@ -551,10 +561,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
position = models.DecimalField(
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
|
||||
verbose_name='Position (U)',
|
||||
help_text='The lowest-numbered unit occupied by the device'
|
||||
)
|
||||
@@ -628,9 +640,10 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'airflow', 'cluster',
|
||||
]
|
||||
clone_fields = (
|
||||
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
|
||||
'cluster', 'virtual_chassis',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name', 'pk') # Name may be null
|
||||
@@ -708,7 +721,11 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
'position': "Cannot select a rack position without assigning a rack.",
|
||||
})
|
||||
|
||||
# Validate position/face combination
|
||||
# Validate rack position and face
|
||||
if self.position and self.position % decimal.Decimal(0.5):
|
||||
raise ValidationError({
|
||||
'position': "Position must be in increments of 0.5 rack units."
|
||||
})
|
||||
if self.position and not self.face:
|
||||
raise ValidationError({
|
||||
'face': "Must specify rack face when defining rack position.",
|
||||
|
||||
@@ -10,7 +10,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',
|
||||
@@ -72,7 +72,7 @@ class PowerPanel(NetBoxModel):
|
||||
)
|
||||
|
||||
|
||||
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
||||
class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
@@ -131,10 +131,10 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
||||
blank=True
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
clone_fields = (
|
||||
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'available_power',
|
||||
]
|
||||
'max_utilization',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
import decimal
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import User
|
||||
@@ -14,11 +14,10 @@ from django.urls import reverse
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.utils import array_to_string
|
||||
from utilities.utils import array_to_string, drange
|
||||
from .device_components import PowerOutlet, PowerPort
|
||||
from .devices import Device
|
||||
from .power import PowerFeed
|
||||
@@ -185,10 +184,10 @@ class Rack(NetBoxModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
clone_fields = (
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit',
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique
|
||||
@@ -247,10 +246,12 @@ class Rack(NetBoxModel):
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
"""
|
||||
Return a list of unit numbers, top to bottom.
|
||||
"""
|
||||
if self.desc_units:
|
||||
return range(1, self.u_height + 1)
|
||||
else:
|
||||
return reversed(range(1, self.u_height + 1))
|
||||
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
|
||||
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
|
||||
|
||||
def get_status_color(self):
|
||||
return RackStatusChoices.colors.get(self.status)
|
||||
@@ -268,12 +269,12 @@ class Rack(NetBoxModel):
|
||||
reference to the device. When False, only the bottom most unit for a device is included and that unit
|
||||
contains a height attribute for the device
|
||||
"""
|
||||
|
||||
elevation = OrderedDict()
|
||||
elevation = {}
|
||||
for u in self.units:
|
||||
u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
|
||||
elevation[u] = {
|
||||
'id': u,
|
||||
'name': f'U{u}',
|
||||
'name': u_name,
|
||||
'face': face,
|
||||
'device': None,
|
||||
'occupied': False
|
||||
@@ -283,7 +284,7 @@ class Rack(NetBoxModel):
|
||||
if self.pk:
|
||||
|
||||
# Retrieve all devices installed within the rack
|
||||
queryset = Device.objects.prefetch_related(
|
||||
devices = Device.objects.prefetch_related(
|
||||
'device_type',
|
||||
'device_type__manufacturer',
|
||||
'device_role'
|
||||
@@ -304,9 +305,9 @@ class Rack(NetBoxModel):
|
||||
if user is not None:
|
||||
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
|
||||
|
||||
for device in queryset:
|
||||
for device in devices:
|
||||
if expand_devices:
|
||||
for u in range(device.position, device.position + device.device_type.u_height):
|
||||
for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
|
||||
if user is None or device.pk in permitted_device_ids:
|
||||
elevation[u]['device'] = device
|
||||
elevation[u]['occupied'] = True
|
||||
@@ -315,8 +316,6 @@ class Rack(NetBoxModel):
|
||||
elevation[device.position]['device'] = device
|
||||
elevation[device.position]['occupied'] = True
|
||||
elevation[device.position]['height'] = device.device_type.u_height
|
||||
for u in range(device.position + 1, device.position + device.device_type.u_height):
|
||||
elevation.pop(u, None)
|
||||
|
||||
return [u for u in elevation.values()]
|
||||
|
||||
@@ -336,12 +335,12 @@ class Rack(NetBoxModel):
|
||||
devices = devices.exclude(pk__in=exclude)
|
||||
|
||||
# Initialize the rack unit skeleton
|
||||
units = list(range(1, self.u_height + 1))
|
||||
units = list(self.units)
|
||||
|
||||
# Remove units consumed by installed devices
|
||||
for d in devices:
|
||||
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
|
||||
for u in range(d.position, d.position + d.device_type.u_height):
|
||||
for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
|
||||
try:
|
||||
units.remove(u)
|
||||
except ValueError:
|
||||
@@ -351,7 +350,7 @@ class Rack(NetBoxModel):
|
||||
# Remove units without enough space above them to accommodate a device of the specified height
|
||||
available_units = []
|
||||
for u in units:
|
||||
if set(range(u, u + u_height)).issubset(units):
|
||||
if set(drange(u, u + u_height, 0.5)).issubset(units):
|
||||
available_units.append(u)
|
||||
|
||||
return list(reversed(available_units))
|
||||
@@ -361,9 +360,9 @@ class Rack(NetBoxModel):
|
||||
Return a dictionary mapping all reserved units within the rack to their reservation.
|
||||
"""
|
||||
reserved_units = {}
|
||||
for r in self.reservations.all():
|
||||
for u in r.units:
|
||||
reserved_units[u] = r
|
||||
for reservation in self.reservations.all():
|
||||
for u in reservation.units:
|
||||
reserved_units[u] = reservation
|
||||
return reserved_units
|
||||
|
||||
def get_elevation_svg(
|
||||
@@ -372,9 +371,11 @@ class Rack(NetBoxModel):
|
||||
user=None,
|
||||
unit_width=None,
|
||||
unit_height=None,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
|
||||
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
|
||||
include_images=True,
|
||||
base_url=None
|
||||
base_url=None,
|
||||
highlight_params=None
|
||||
):
|
||||
"""
|
||||
Return an SVG of the rack elevation
|
||||
@@ -386,16 +387,23 @@ class Rack(NetBoxModel):
|
||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||
height of the elevation
|
||||
:param legend_width: Width of the unit legend, in pixels
|
||||
:param margin_width: Width of the rigth-hand margin, in pixels
|
||||
:param include_images: Embed front/rear device images where available
|
||||
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
||||
"""
|
||||
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
|
||||
if unit_width is None or unit_height is None:
|
||||
config = get_config()
|
||||
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
||||
elevation = RackElevationSVG(
|
||||
self,
|
||||
unit_width=unit_width,
|
||||
unit_height=unit_height,
|
||||
legend_width=legend_width,
|
||||
margin_width=margin_width,
|
||||
user=user,
|
||||
include_images=include_images,
|
||||
base_url=base_url,
|
||||
highlight_params=highlight_params
|
||||
)
|
||||
|
||||
return elevation.render(face, unit_width, unit_height, legend_width)
|
||||
return elevation.render(face)
|
||||
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
@@ -406,6 +414,7 @@ class Rack(NetBoxModel):
|
||||
as utilized.
|
||||
"""
|
||||
# Determine unoccupied units
|
||||
total_units = len(list(self.units))
|
||||
available_units = self.get_available_units()
|
||||
|
||||
# Remove reserved units
|
||||
@@ -413,8 +422,8 @@ class Rack(NetBoxModel):
|
||||
if u in available_units:
|
||||
available_units.remove(u)
|
||||
|
||||
occupied_unit_count = self.u_height - len(available_units)
|
||||
percentage = float(occupied_unit_count) / self.u_height * 100
|
||||
occupied_unit_count = total_units - len(available_units)
|
||||
percentage = float(occupied_unit_count) / total_units * 100
|
||||
|
||||
return percentage
|
||||
|
||||
@@ -427,17 +436,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):
|
||||
|
||||
@@ -295,10 +295,10 @@ class Site(NetBoxModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude',
|
||||
]
|
||||
clone_fields = (
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
|
||||
'latitude', 'longitude', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name',)
|
||||
@@ -341,6 +341,11 @@ class Location(NestedGroupModel):
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=LocationStatusChoices,
|
||||
default=LocationStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -367,7 +372,7 @@ class Location(NestedGroupModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ['site', 'parent', 'tenant', 'description']
|
||||
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -413,6 +418,9 @@ class Location(NestedGroupModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return LocationStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@@ -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,58 @@ 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 instance._terminations_modified:
|
||||
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, and retrace any affected CablePaths.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
model = instance.termination_type.model_class()
|
||||
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
|
||||
|
||||
# Disassociate the Cable from its termination points
|
||||
if instance.termination_a is not None:
|
||||
logger.debug(f"Nullifying termination A for cable {instance}")
|
||||
model = instance.termination_a._meta.model
|
||||
model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
|
||||
if instance.termination_b is not None:
|
||||
logger.debug(f"Nullifying termination B for cable {instance}")
|
||||
model = instance.termination_b._meta.model
|
||||
model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
|
||||
|
||||
# Delete and retrace any dependent cable paths
|
||||
for cablepath in CablePath.objects.filter(path__contains=instance):
|
||||
cp = CablePath.from_origin(cablepath.origin)
|
||||
if cp:
|
||||
CablePath.objects.filter(pk=cablepath.pk).update(
|
||||
path=cp.path,
|
||||
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
|
||||
destination_id=cp.destination.pk if cp.destination else None,
|
||||
is_active=cp.is_active,
|
||||
is_split=cp.is_split
|
||||
)
|
||||
else:
|
||||
cablepath.delete()
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
cablepath.retrace()
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
import svgwrite
|
||||
from svgwrite.container import Group, Hyperlink
|
||||
from svgwrite.shapes import Line, Rect
|
||||
from svgwrite.text import Text
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from utilities.utils import foreground_color
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import RACK_ELEVATION_BORDER_WIDTH
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CableTraceSVG',
|
||||
'RackElevationSVG',
|
||||
)
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
return f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
return device.name
|
||||
else:
|
||||
return str(device.device_type)
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
"""
|
||||
Use this class to render a rack elevation as an SVG image.
|
||||
|
||||
:param rack: A NetBox Rack instance
|
||||
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
|
||||
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
||||
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
|
||||
"""
|
||||
def __init__(self, rack, user=None, include_images=True, base_url=None):
|
||||
self.rack = rack
|
||||
self.include_images = include_images
|
||||
if base_url is not None:
|
||||
self.base_url = base_url.rstrip('/')
|
||||
else:
|
||||
self.base_url = ''
|
||||
|
||||
# Determine the subset of devices within this rack that are viewable by the user, if any
|
||||
permitted_devices = self.rack.devices
|
||||
if user is not None:
|
||||
permitted_devices = permitted_devices.restrict(user, 'view')
|
||||
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
|
||||
|
||||
@staticmethod
|
||||
def _get_device_description(device):
|
||||
return '{} ({}) — {} {} ({}U) {} {}'.format(
|
||||
device.name,
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
device.device_type.u_height,
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_gradient(drawing, id_, color):
|
||||
gradient = drawing.linearGradient(
|
||||
start=(0, 0),
|
||||
end=(0, 25),
|
||||
spreadMethod='repeat',
|
||||
id_=id_,
|
||||
gradientTransform='rotate(45, 0, 0)',
|
||||
gradientUnits='userSpaceOnUse'
|
||||
)
|
||||
gradient.add_stop_color(offset='0%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color=color)
|
||||
gradient.add_stop_color(offset='100%', color=color)
|
||||
drawing.defs.add(gradient)
|
||||
|
||||
@staticmethod
|
||||
def _setup_drawing(width, height):
|
||||
drawing = svgwrite.Drawing(size=(width, height))
|
||||
|
||||
# add the stylesheet
|
||||
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# add gradients
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
|
||||
return drawing
|
||||
|
||||
def _draw_device_front(self, drawing, device, start, end, text):
|
||||
name = get_device_name(device)
|
||||
if device.devicebay_count:
|
||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
||||
|
||||
color = device.device_role.color
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
||||
target='_top',
|
||||
fill='black'
|
||||
)
|
||||
)
|
||||
link.set_desc(self._get_device_description(device))
|
||||
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
|
||||
hex_color = '#{}'.format(foreground_color(color))
|
||||
link.add(drawing.text(str(name), insert=text, fill=hex_color))
|
||||
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
url = device.device_type.front_image.url
|
||||
# Convert any relative URLs to absolute
|
||||
if url.startswith('/'):
|
||||
url = '{}{}'.format(self.base_url, url)
|
||||
image = drawing.image(
|
||||
href=url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
link.add(drawing.text(str(name), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
||||
target='_top',
|
||||
fill='black'
|
||||
)
|
||||
)
|
||||
link.set_desc(self._get_device_description(device))
|
||||
link.add(drawing.rect(start, end, class_="slot blocked"))
|
||||
link.add(drawing.text(get_device_name(device), insert=text))
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
url = device.device_type.rear_image.url
|
||||
# Convert any relative URLs to absolute
|
||||
if url.startswith('/'):
|
||||
url = '{}{}'.format(self.base_url, url)
|
||||
image = drawing.image(
|
||||
href=url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}{}?{}'.format(
|
||||
self.base_url,
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': rack.site.pk,
|
||||
'location': rack.location.pk if rack.location else '',
|
||||
'rack': rack.pk,
|
||||
'face': face_id,
|
||||
'position': id_
|
||||
})
|
||||
)
|
||||
link = drawing.add(
|
||||
drawing.a(href=link_url, target='_top')
|
||||
)
|
||||
if reservation:
|
||||
link.set_desc('{} — {} · {}'.format(
|
||||
reservation.description, reservation.user, reservation.created
|
||||
))
|
||||
link.add(drawing.rect(start, end, class_=class_))
|
||||
link.add(drawing.text("add device", insert=text, class_='add-device'))
|
||||
|
||||
def merge_elevations(self, face):
|
||||
elevation = self.rack.get_rack_units(face=face, expand_devices=False)
|
||||
if face == DeviceFaceChoices.FACE_REAR:
|
||||
other_face = DeviceFaceChoices.FACE_FRONT
|
||||
else:
|
||||
other_face = DeviceFaceChoices.FACE_REAR
|
||||
other = self.rack.get_rack_units(face=other_face)
|
||||
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
||||
return elevation
|
||||
|
||||
def render(self, face, unit_width, unit_height, legend_width):
|
||||
"""
|
||||
Return an SVG document representing a rack elevation.
|
||||
"""
|
||||
drawing = self._setup_drawing(
|
||||
unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
|
||||
unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||
)
|
||||
reserved_units = self.rack.get_reserved_units()
|
||||
|
||||
unit_cursor = 0
|
||||
for ru in range(0, self.rack.u_height):
|
||||
start_y = ru * unit_height
|
||||
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||
drawing.add(
|
||||
drawing.text(str(unit), position_coordinates, class_="unit")
|
||||
)
|
||||
|
||||
for unit in self.merge_elevations(face):
|
||||
|
||||
# Loop through all units in the elevation
|
||||
device = unit['device']
|
||||
height = unit.get('height', 1)
|
||||
|
||||
# Setup drawing coordinates
|
||||
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
|
||||
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||
end_y = unit_height * height
|
||||
start_cordinates = (x_offset, y_offset)
|
||||
end_cordinates = (unit_width, end_y)
|
||||
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
|
||||
|
||||
# Draw the device
|
||||
if device and device.face == face and device.pk in self.permitted_device_ids:
|
||||
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
|
||||
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
elif device:
|
||||
# Devices which the user does not have permission to view are rendered only as unavailable space
|
||||
drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
|
||||
else:
|
||||
# Draw shallow devices, reservations, or empty units
|
||||
class_ = 'slot'
|
||||
reservation = reserved_units.get(unit["id"])
|
||||
if device:
|
||||
class_ += ' occupied'
|
||||
if reservation:
|
||||
class_ += ' reserved'
|
||||
self._draw_empty(
|
||||
drawing,
|
||||
self.rack,
|
||||
start_cordinates,
|
||||
end_cordinates,
|
||||
text_cordinates,
|
||||
unit["id"],
|
||||
face,
|
||||
class_,
|
||||
reservation
|
||||
)
|
||||
|
||||
unit_cursor += height
|
||||
|
||||
# Wrap the drawing with a border
|
||||
border_width = RACK_ELEVATION_BORDER_WIDTH
|
||||
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
|
||||
frame = drawing.rect(
|
||||
insert=(legend_width + border_offset, border_offset),
|
||||
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
|
||||
class_='rack'
|
||||
)
|
||||
drawing.add(frame)
|
||||
|
||||
return drawing
|
||||
|
||||
|
||||
OFFSET = 0.5
|
||||
PADDING = 10
|
||||
LINE_HEIGHT = 20
|
||||
|
||||
|
||||
class CableTraceSVG:
|
||||
"""
|
||||
Generate a graphical representation of a CablePath in SVG format.
|
||||
|
||||
:param origin: The originating termination
|
||||
: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):
|
||||
self.origin = origin
|
||||
self.width = width
|
||||
self.base_url = base_url.rstrip('/') if base_url is not None else ''
|
||||
|
||||
# Establish a cursor to track position on the y axis
|
||||
# Center edges on pixels to render sharp borders
|
||||
self.cursor = OFFSET
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
return self.width / 2
|
||||
|
||||
@classmethod
|
||||
def _get_labels(cls, instance):
|
||||
"""
|
||||
Return a list of text labels for the given instance based on model type.
|
||||
"""
|
||||
labels = [str(instance)]
|
||||
if instance._meta.model_name == 'device':
|
||||
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
|
||||
location_label = f'{instance.site}'
|
||||
if instance.location:
|
||||
location_label += f' / {instance.location}'
|
||||
if instance.rack:
|
||||
location_label += f' / {instance.rack}'
|
||||
labels.append(location_label)
|
||||
elif instance._meta.model_name == 'circuit':
|
||||
labels[0] = f'Circuit {instance}'
|
||||
labels.append(instance.provider)
|
||||
elif instance._meta.model_name == 'circuittermination':
|
||||
if instance.xconnect_id:
|
||||
labels.append(f'{instance.xconnect_id}')
|
||||
elif instance._meta.model_name == 'providernetwork':
|
||||
labels.append(instance.provider)
|
||||
|
||||
return labels
|
||||
|
||||
@classmethod
|
||||
def _get_color(cls, instance):
|
||||
"""
|
||||
Return the appropriate fill color for an object within a cable path.
|
||||
"""
|
||||
if hasattr(instance, 'parent_object'):
|
||||
# Termination
|
||||
return 'f0f0f0'
|
||||
if hasattr(instance, 'device_role'):
|
||||
# Device
|
||||
return instance.device_role.color
|
||||
else:
|
||||
# Other parent object
|
||||
return 'e0e0e0'
|
||||
|
||||
def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
|
||||
"""
|
||||
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)
|
||||
"""
|
||||
self.cursor -= y_indent
|
||||
|
||||
# Create a hyperlink
|
||||
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
|
||||
|
||||
# Add the box
|
||||
position = (
|
||||
OFFSET + (self.width - width) / 2,
|
||||
self.cursor
|
||||
)
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
self.cursor += PADDING * padding_multiplier
|
||||
|
||||
return link
|
||||
|
||||
def _draw_cable(self, color, url, labels):
|
||||
"""
|
||||
Return an SVG group containing a line element and text labels representing a Cable.
|
||||
|
||||
: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):
|
||||
"""
|
||||
Draw a line with labels representing a WirelessLink.
|
||||
|
||||
:param url: Hyperlink URL
|
||||
:param labels: Iterable of text labels
|
||||
"""
|
||||
group = Group(class_='connector')
|
||||
|
||||
# Draw the wireless link
|
||||
start = (OFFSET + self.center, self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='wireless-link')
|
||||
group.add(line)
|
||||
|
||||
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_attachment(self):
|
||||
"""
|
||||
Return an SVG group containing a line element and "Attachment" label.
|
||||
"""
|
||||
group = Group(class_='connector')
|
||||
|
||||
# Draw attachment (line)
|
||||
start = (OFFSET + self.center, OFFSET + self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='attachment')
|
||||
group.add(line)
|
||||
self.cursor += PADDING * 4
|
||||
|
||||
return group
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Return an SVG document representing a cable trace.
|
||||
"""
|
||||
from dcim.models import Cable
|
||||
from wireless.models import WirelessLink
|
||||
|
||||
traced_path = self.origin.trace()
|
||||
|
||||
# Prep elements list
|
||||
parent_objects = []
|
||||
terminations = []
|
||||
connectors = []
|
||||
|
||||
# Iterate through each (term, cable, term) segment in the path
|
||||
for i, segment in enumerate(traced_path):
|
||||
near_end, connector, far_end = 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if connector is not None:
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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 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)
|
||||
|
||||
elif far_end:
|
||||
|
||||
# Attachment
|
||||
attachment = self._draw_attachment()
|
||||
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)
|
||||
|
||||
# Determine drawing size
|
||||
self.drawing = svgwrite.Drawing(
|
||||
size=(self.width, self.cursor + 2)
|
||||
)
|
||||
|
||||
# Attach CSS stylesheet
|
||||
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
|
||||
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:
|
||||
self.drawing.add(element)
|
||||
|
||||
return self.drawing
|
||||
2
netbox/dcim/svg/__init__.py
Normal file
2
netbox/dcim/svg/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cables import *
|
||||
from .racks import *
|
||||
399
netbox/dcim/svg/cables.py
Normal file
399
netbox/dcim/svg/cables.py
Normal file
@@ -0,0 +1,399 @@
|
||||
import svgwrite
|
||||
from svgwrite.container import Group, Hyperlink
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Generate a graphical representation of a CablePath in SVG format.
|
||||
|
||||
:param origin: The originating termination
|
||||
: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=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 ''
|
||||
|
||||
# Establish a cursor to track position on the y axis
|
||||
# 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
|
||||
|
||||
@classmethod
|
||||
def _get_labels(cls, instance):
|
||||
"""
|
||||
Return a list of text labels for the given instance based on model type.
|
||||
"""
|
||||
labels = [str(instance)]
|
||||
if instance._meta.model_name == 'device':
|
||||
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
|
||||
location_label = f'{instance.site}'
|
||||
if instance.location:
|
||||
location_label += f' / {instance.location}'
|
||||
if instance.rack:
|
||||
location_label += f' / {instance.rack}'
|
||||
labels.append(location_label)
|
||||
elif instance._meta.model_name == 'circuit':
|
||||
labels[0] = f'Circuit {instance}'
|
||||
labels.append(instance.provider)
|
||||
elif instance._meta.model_name == 'circuittermination':
|
||||
if instance.xconnect_id:
|
||||
labels.append(f'{instance.xconnect_id}')
|
||||
elif instance._meta.model_name == 'providernetwork':
|
||||
labels.append(instance.provider)
|
||||
|
||||
return labels
|
||||
|
||||
@classmethod
|
||||
def _get_color(cls, instance):
|
||||
"""
|
||||
Return the appropriate fill color for an object within a cable path.
|
||||
"""
|
||||
if hasattr(instance, 'parent_object'):
|
||||
# Termination
|
||||
return 'f0f0f0'
|
||||
if hasattr(instance, 'device_role'):
|
||||
# Device
|
||||
return instance.device_role.color
|
||||
else:
|
||||
# Other parent object
|
||||
return 'e0e0e0'
|
||||
|
||||
def draw_parent_objects(self, obj_list):
|
||||
"""
|
||||
Draw a set of parent objects.
|
||||
"""
|
||||
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']
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
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}'),
|
||||
))
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
|
||||
def draw_wirelesslink(self, wirelesslink):
|
||||
"""
|
||||
Draw a line with labels representing a WirelessLink.
|
||||
"""
|
||||
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
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='wireless-link')
|
||||
group.add(line)
|
||||
|
||||
self.cursor += PADDING * 2
|
||||
|
||||
# Add link
|
||||
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_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_attachment(self):
|
||||
"""
|
||||
Return an SVG group containing a line element and "Attachment" label.
|
||||
"""
|
||||
group = Group(class_='connector')
|
||||
|
||||
# Draw attachment (line)
|
||||
start = (OFFSET + self.center, OFFSET + self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='attachment')
|
||||
group.add(line)
|
||||
self.cursor += PADDING * 4
|
||||
|
||||
return group
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Return an SVG document representing a cable trace.
|
||||
"""
|
||||
from dcim.models import Cable
|
||||
from wireless.models import WirelessLink
|
||||
|
||||
traced_path = self.origin.trace()
|
||||
|
||||
# Iterate through each (terms, cable, terms) segment in the path
|
||||
for i, segment in enumerate(traced_path):
|
||||
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
|
||||
self.draw_parent_objects(set(end.parent_object for end in near_ends))
|
||||
|
||||
# Near end termination(s)
|
||||
terminations = self.draw_terminations(near_ends)
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link = links[0] # Remove Cable from list
|
||||
|
||||
# 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(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
|
||||
# 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)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
# Link is not connected to anything
|
||||
break
|
||||
|
||||
# Far end parent
|
||||
parent_objects = set(end.parent_object for end in far_ends)
|
||||
self.draw_parent_objects(parent_objects)
|
||||
|
||||
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
|
||||
# a CircuitTermination)
|
||||
elif far_ends:
|
||||
|
||||
# Attachment
|
||||
attachment = self.draw_attachment()
|
||||
self.connectors.append(attachment)
|
||||
|
||||
# Object
|
||||
self.draw_parent_objects(far_ends)
|
||||
|
||||
# Determine drawing size
|
||||
self.drawing = svgwrite.Drawing(
|
||||
size=(self.width, self.cursor + 2)
|
||||
)
|
||||
|
||||
# Attach CSS stylesheet
|
||||
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
|
||||
self.drawing.defs.add(self.drawing.style(css_file.read()))
|
||||
|
||||
# Add elements to the drawing in order of depth (Z axis)
|
||||
for element in self.connectors + self.parent_objects + self.terminations:
|
||||
self.drawing.add(element)
|
||||
|
||||
return self.drawing
|
||||
323
netbox/dcim/svg/racks.py
Normal file
323
netbox/dcim/svg/racks.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import decimal
|
||||
import svgwrite
|
||||
from svgwrite.container import Hyperlink
|
||||
from svgwrite.image import Image
|
||||
from svgwrite.gradients import LinearGradient
|
||||
from svgwrite.shapes import Rect
|
||||
from svgwrite.text import Text
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from netbox.config import get_config
|
||||
from utilities.utils import foreground_color, array_to_ranges
|
||||
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
|
||||
|
||||
|
||||
__all__ = (
|
||||
'RackElevationSVG',
|
||||
)
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
name = device.name
|
||||
else:
|
||||
name = str(device.device_type)
|
||||
if device.devicebay_count:
|
||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def get_device_description(device):
|
||||
return '{} ({}) — {} {} ({}U) {} {}'.format(
|
||||
device.name,
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
device.device_type.u_height,
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
"""
|
||||
Use this class to render a rack elevation as an SVG image.
|
||||
|
||||
:param rack: A NetBox Rack instance
|
||||
:param unit_width: Rendered unit width, in pixels
|
||||
:param unit_height: Rendered unit height, in pixels
|
||||
:param legend_width: Legend width, in pixels (where the unit labels appear)
|
||||
:param margin_width: Margin width, in pixels (where reservations appear)
|
||||
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
|
||||
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
||||
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
|
||||
:param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
|
||||
"""
|
||||
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
|
||||
include_images=True, base_url=None, highlight_params=None):
|
||||
self.rack = rack
|
||||
self.include_images = include_images
|
||||
self.base_url = base_url.rstrip('/') if base_url is not None else ''
|
||||
|
||||
# Set drawing dimensions
|
||||
config = get_config()
|
||||
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
||||
self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
|
||||
self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
|
||||
|
||||
# Determine the subset of devices within this rack that are viewable by the user, if any
|
||||
permitted_devices = self.rack.devices
|
||||
if user is not None:
|
||||
permitted_devices = permitted_devices.restrict(user, 'view')
|
||||
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
|
||||
|
||||
# Determine device(s) to highlight within the elevation (if any)
|
||||
self.highlight_devices = []
|
||||
if highlight_params:
|
||||
q = Q()
|
||||
for k, v in highlight_params:
|
||||
q |= Q(**{k: v})
|
||||
try:
|
||||
self.highlight_devices = permitted_devices.filter(q)
|
||||
except FieldError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _add_gradient(drawing, id_, color):
|
||||
gradient = LinearGradient(
|
||||
start=(0, 0),
|
||||
end=(0, 25),
|
||||
spreadMethod='repeat',
|
||||
id_=id_,
|
||||
gradientTransform='rotate(45, 0, 0)',
|
||||
gradientUnits='userSpaceOnUse'
|
||||
)
|
||||
gradient.add_stop_color(offset='0%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color=color)
|
||||
gradient.add_stop_color(offset='100%', color=color)
|
||||
|
||||
drawing.defs.add(gradient)
|
||||
|
||||
def _setup_drawing(self):
|
||||
width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||
drawing = svgwrite.Drawing(size=(width, height))
|
||||
|
||||
# Add the stylesheet
|
||||
with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# Add gradients
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
|
||||
return drawing
|
||||
|
||||
def _get_device_coords(self, position, height):
|
||||
"""
|
||||
Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
|
||||
"""
|
||||
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
|
||||
y = RACK_ELEVATION_BORDER_WIDTH
|
||||
if self.rack.desc_units:
|
||||
y += int((position - 1) * self.unit_height)
|
||||
else:
|
||||
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
|
||||
|
||||
return x, y
|
||||
|
||||
def _draw_device(self, device, coords, size, color=None, image=None):
|
||||
name = get_device_name(device)
|
||||
description = get_device_description(device)
|
||||
text_color = f'#{foreground_color(color)}' if color else '#000000'
|
||||
text_coords = (
|
||||
coords[0] + size[0] / 2,
|
||||
coords[1] + size[1] / 2
|
||||
)
|
||||
|
||||
# Determine whether highlighting is in use, and if so, whether to shade this device
|
||||
is_shaded = self.highlight_devices and device not in self.highlight_devices
|
||||
css_extra = ' shaded' if is_shaded else ''
|
||||
|
||||
# Create hyperlink element
|
||||
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
|
||||
link.set_desc(description)
|
||||
|
||||
# Add rect element to hyperlink
|
||||
if color:
|
||||
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
|
||||
else:
|
||||
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
|
||||
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
|
||||
|
||||
# Embed device type image if provided
|
||||
if self.include_images and image:
|
||||
url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
|
||||
image = Image(
|
||||
href=url,
|
||||
insert=coords,
|
||||
size=size,
|
||||
class_=f'device-image{css_extra}'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
link.add(
|
||||
Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
|
||||
class_=f'device-image-label{css_extra}')
|
||||
)
|
||||
link.add(
|
||||
Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
|
||||
)
|
||||
|
||||
self.drawing.add(link)
|
||||
|
||||
def draw_device_front(self, device, coords, size):
|
||||
"""
|
||||
Draw the front (mounted) face of a device.
|
||||
"""
|
||||
color = device.device_role.color
|
||||
image = device.device_type.front_image
|
||||
self._draw_device(device, coords, size, color=color, image=image)
|
||||
|
||||
def draw_device_rear(self, device, coords, size):
|
||||
"""
|
||||
Draw the rear (opposite) face of a device.
|
||||
"""
|
||||
image = device.device_type.rear_image
|
||||
self._draw_device(device, coords, size, image=image)
|
||||
|
||||
def draw_border(self):
|
||||
"""
|
||||
Draw a border around the collection of rack units.
|
||||
"""
|
||||
border_width = RACK_ELEVATION_BORDER_WIDTH
|
||||
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
|
||||
frame = Rect(
|
||||
insert=(self.legend_width + border_offset, border_offset),
|
||||
size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
|
||||
class_='rack'
|
||||
)
|
||||
self.drawing.add(frame)
|
||||
|
||||
def draw_legend(self):
|
||||
"""
|
||||
Draw the rack unit labels along the lefthand side of the elevation.
|
||||
"""
|
||||
for ru in range(0, self.rack.u_height):
|
||||
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||
self.drawing.add(
|
||||
Text(str(unit), position_coordinates, class_='unit')
|
||||
)
|
||||
|
||||
def draw_margin(self):
|
||||
"""
|
||||
Draw any rack reservations in the right-hand margin alongside the rack elevation.
|
||||
"""
|
||||
for reservation in self.rack.reservations.all():
|
||||
for segment in array_to_ranges(reservation.units):
|
||||
u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
|
||||
coords = self._get_device_coords(segment[0], u_height)
|
||||
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
|
||||
size = (
|
||||
self.margin_width,
|
||||
u_height * self.unit_height
|
||||
)
|
||||
link = Hyperlink(
|
||||
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
|
||||
target='_blank'
|
||||
)
|
||||
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
|
||||
link.add(
|
||||
Rect(coords, size, class_='reservation')
|
||||
)
|
||||
self.drawing.add(link)
|
||||
|
||||
def draw_background(self, face):
|
||||
"""
|
||||
Draw the rack unit placeholders which form the "background" of the rack elevation.
|
||||
"""
|
||||
x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
|
||||
url_string = '{}?{}&position={{}}'.format(
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': self.rack.site.pk,
|
||||
'location': self.rack.location.pk if self.rack.location else '',
|
||||
'rack': self.rack.pk,
|
||||
'face': face,
|
||||
})
|
||||
)
|
||||
|
||||
for ru in range(0, self.rack.u_height):
|
||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
|
||||
text_coords = (
|
||||
x_offset + self.unit_width / 2,
|
||||
y_offset + self.unit_height / 2
|
||||
)
|
||||
|
||||
link = Hyperlink(href=url_string.format(unit), target='_blank')
|
||||
link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
|
||||
link.add(Text('add device', insert=text_coords, class_='add-device'))
|
||||
|
||||
self.drawing.add(link)
|
||||
|
||||
def draw_face(self, face, opposite=False):
|
||||
"""
|
||||
Draw any occupied rack units for the specified rack face.
|
||||
"""
|
||||
for unit in self.rack.get_rack_units(face=face, expand_devices=False):
|
||||
|
||||
# Loop through all units in the elevation
|
||||
device = unit['device']
|
||||
height = unit.get('height', decimal.Decimal(1.0))
|
||||
|
||||
device_coords = self._get_device_coords(unit['id'], height)
|
||||
device_size = (
|
||||
self.unit_width,
|
||||
int(self.unit_height * height)
|
||||
)
|
||||
|
||||
# Draw the device
|
||||
if device and device.pk in self.permitted_device_ids:
|
||||
if device.face == face and not opposite:
|
||||
self.draw_device_front(device, device_coords, device_size)
|
||||
else:
|
||||
self.draw_device_rear(device, device_coords, device_size)
|
||||
|
||||
elif device:
|
||||
# Devices which the user does not have permission to view are rendered only as unavailable space
|
||||
self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
|
||||
|
||||
def render(self, face):
|
||||
"""
|
||||
Return an SVG document representing a rack elevation.
|
||||
"""
|
||||
|
||||
# Initialize the drawing
|
||||
self.drawing = self._setup_drawing()
|
||||
|
||||
# Draw the empty rack, legend, and margin
|
||||
self.draw_legend()
|
||||
self.draw_background(face)
|
||||
self.draw_margin()
|
||||
|
||||
# Draw the rack face
|
||||
self.draw_face(face)
|
||||
|
||||
# Draw the rack border last
|
||||
self.draw_border()
|
||||
|
||||
return self.drawing
|
||||
@@ -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 TenancyColumnsMixin
|
||||
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
|
||||
from .template_code import CABLE_LENGTH
|
||||
|
||||
__all__ = (
|
||||
'CableTable',
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationsColumn(tables.Column):
|
||||
"""
|
||||
Args:
|
||||
cable_end: Which side of the cable to report on (A or B)
|
||||
attr: The CableTermination attribute to return for each instance (returns the termination object by default)
|
||||
"""
|
||||
def __init__(self, cable_end, attr='termination', *args, **kwargs):
|
||||
self.cable_end = cable_end
|
||||
self.attr = attr
|
||||
super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
|
||||
|
||||
def _get_terminations(self, manager):
|
||||
terminations = set()
|
||||
for cabletermination in manager.all():
|
||||
if cabletermination.cable_end == self.cable_end:
|
||||
if termination := getattr(cabletermination, self.attr, None):
|
||||
terminations.add(termination)
|
||||
|
||||
return terminations
|
||||
|
||||
def render(self, value):
|
||||
links = [
|
||||
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
|
||||
]
|
||||
return mark_safe('<br />'.join(links) or '—')
|
||||
|
||||
def value(self, value):
|
||||
return ','.join([str(t) for t in self._get_terminations(value)])
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableTable(TenancyColumnsMixin, 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()
|
||||
length = columns.TemplateColumn(
|
||||
@@ -65,10 +118,10 @@ class CableTable(TenancyColumnsMixin, 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', 'tenant_group', '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', 'tenant_group', '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',
|
||||
)
|
||||
|
||||
@@ -274,17 +274,17 @@ class CableTerminationTable(NetBoxTable):
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
link_peer = columns.TemplateColumn(
|
||||
accessor='_link_peer',
|
||||
accessor='link_peers',
|
||||
template_code=LINKTERMINATION,
|
||||
orderable=False,
|
||||
verbose_name='Link Peer'
|
||||
verbose_name='Link Peers'
|
||||
)
|
||||
mark_connected = columns.BooleanColumn()
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = columns.TemplateColumn(
|
||||
accessor='_path__last_node',
|
||||
accessor='_path__destinations',
|
||||
template_code=LINKTERMINATION,
|
||||
verbose_name='Connection',
|
||||
orderable=False
|
||||
@@ -518,10 +518,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
|
||||
'tagged_vlans', 'created', 'last_updated',
|
||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
|
||||
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = InterfaceTemplate
|
||||
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
|
||||
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
rack_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:rack_list',
|
||||
url_params={'location_id': 'pk'},
|
||||
@@ -148,7 +149,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description',
|
||||
'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
|
||||
default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
LINKTERMINATION = """
|
||||
{% if value %}
|
||||
{% if value.parent_object %}
|
||||
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
|
||||
{% for termination in value %}
|
||||
{% if termination.parent_object %}
|
||||
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
{% endif %}
|
||||
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% empty %}
|
||||
{{ ''|placeholder }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
@@ -13,16 +15,6 @@ CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
{% if value.device %}
|
||||
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
|
||||
{% elif value.circuit %}
|
||||
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
|
||||
{% elif value.power_panel %}
|
||||
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
@@ -113,7 +105,7 @@ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
|
||||
|
||||
CONSOLEPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -133,9 +125,9 @@ CONSOLEPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -145,7 +137,7 @@ CONSOLEPORT_BUTTONS = """
|
||||
|
||||
CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -165,9 +157,9 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -177,7 +169,7 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
|
||||
POWERPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -197,8 +189,8 @@ POWERPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.poweroutlet&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerfeed&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -208,7 +200,7 @@ POWERPORT_BUTTONS = """
|
||||
|
||||
POWEROUTLET_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -224,7 +216,7 @@ POWEROUTLET_BUTTONS = """
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -244,7 +236,7 @@ INTERFACE_BUTTONS = """
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">IP Address</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
@@ -274,17 +266,17 @@ INTERFACE_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.site.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
|
||||
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device.pk }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
||||
<span class="mdi mdi-wifi-plus" aria-hidden="true"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -292,7 +284,7 @@ INTERFACE_BUTTONS = """
|
||||
|
||||
FRONTPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -313,12 +305,12 @@ FRONTPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.site.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -329,7 +321,7 @@ FRONTPORT_BUTTONS = """
|
||||
|
||||
REARPORT_BUTTONS = """
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -350,12 +342,12 @@ REARPORT_BUTTONS = """
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.site.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -385,7 +377,7 @@ MODULEBAY_BUTTONS = """
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,6 +7,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.api.serializers import GenericObjectSerializer
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices
|
||||
@@ -45,7 +46,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 +56,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):
|
||||
@@ -197,13 +198,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
parent_locations = (
|
||||
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
|
||||
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
|
||||
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
|
||||
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
|
||||
)
|
||||
|
||||
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
|
||||
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
|
||||
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
|
||||
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
@@ -211,18 +212,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'test-location-4',
|
||||
'site': sites[1].pk,
|
||||
'parent': parent_locations[1].pk,
|
||||
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||
},
|
||||
{
|
||||
'name': 'Test Location 5',
|
||||
'slug': 'test-location-5',
|
||||
'site': sites[1].pk,
|
||||
'parent': parent_locations[1].pk,
|
||||
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||
},
|
||||
{
|
||||
'name': 'Test Location 6',
|
||||
'slug': 'test-location-6',
|
||||
'site': sites[1].pk,
|
||||
'parent': parent_locations[1].pk,
|
||||
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -327,15 +331,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
# Retrieve all units
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['count'], 42)
|
||||
self.assertEqual(response.data['count'], 84)
|
||||
|
||||
# Search for specific units
|
||||
response = self.client.get(f'{url}?q=3', **self.header)
|
||||
self.assertEqual(response.data['count'], 13)
|
||||
self.assertEqual(response.data['count'], 26)
|
||||
response = self.client.get(f'{url}?q=U3', **self.header)
|
||||
self.assertEqual(response.data['count'], 11)
|
||||
self.assertEqual(response.data['count'], 22)
|
||||
response = self.client.get(f'{url}?q=U10', **self.header)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
|
||||
def test_get_rack_elevation_svg(self):
|
||||
"""
|
||||
@@ -1507,6 +1511,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
'speed': 1000000,
|
||||
'duplex': 'full',
|
||||
'vrf': vrfs[0].pk,
|
||||
'poe_mode': InterfacePoEModeChoices.MODE_PD,
|
||||
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||
'untagged_vlan': vlans[2].pk,
|
||||
},
|
||||
@@ -1859,6 +1865,17 @@ class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
# TODO: Allow updating cable terminations
|
||||
test_update_object = None
|
||||
|
||||
def model_to_dict(self, *args, **kwargs):
|
||||
data = super().model_to_dict(*args, **kwargs)
|
||||
|
||||
# Serialize termination objects
|
||||
if 'a_terminations' in data:
|
||||
data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data
|
||||
if 'b_terminations' in data:
|
||||
data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
@@ -1879,33 +1896,45 @@ 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': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': interfaces[4].pk,
|
||||
}],
|
||||
'b_terminations': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': 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': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': interfaces[5].pk,
|
||||
}],
|
||||
'b_terminations': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': 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': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': interfaces[6].pk,
|
||||
}],
|
||||
'b_terminations': [{
|
||||
'object_type': 'dcim.interface',
|
||||
'object_id': interfaces[16].pk,
|
||||
}],
|
||||
'label': 'Cable 6',
|
||||
},
|
||||
]
|
||||
@@ -1931,7 +1960,7 @@ class ConnectedDeviceTest(APITestCase):
|
||||
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
|
||||
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
|
||||
|
||||
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
|
||||
cable.save()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
location.save()
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
|
||||
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
@@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'slug': ['location-1', 'location-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1085,8 +1089,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
InterfaceTemplate.objects.bulk_create((
|
||||
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True),
|
||||
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False),
|
||||
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF),
|
||||
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT),
|
||||
InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False),
|
||||
))
|
||||
|
||||
@@ -1109,6 +1113,14 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'mgmt_only': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_poe_mode(self):
|
||||
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_poe_type(self):
|
||||
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = FrontPortTemplate.objects.all()
|
||||
@@ -1948,8 +1960,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):
|
||||
@@ -2095,8 +2107,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):
|
||||
@@ -2242,8 +2254,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):
|
||||
@@ -2397,8 +2409,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):
|
||||
@@ -2547,20 +2559,115 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
|
||||
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
|
||||
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
|
||||
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
|
||||
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
|
||||
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
|
||||
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
|
||||
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
|
||||
Interface(
|
||||
device=devices[0],
|
||||
module=modules[0],
|
||||
name='Interface 1',
|
||||
label='A',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||
enabled=True,
|
||||
mgmt_only=True,
|
||||
mtu=100,
|
||||
mode=InterfaceModeChoices.MODE_ACCESS,
|
||||
mac_address='00-00-00-00-00-01',
|
||||
description='First',
|
||||
vrf=vrfs[0],
|
||||
speed=1000000,
|
||||
duplex='half',
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||
),
|
||||
Interface(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='Interface 2',
|
||||
label='B',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
enabled=True,
|
||||
mgmt_only=True,
|
||||
mtu=200,
|
||||
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||
mac_address='00-00-00-00-00-02',
|
||||
description='Second',
|
||||
vrf=vrfs[1],
|
||||
speed=1000000,
|
||||
duplex='full',
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Interface 3',
|
||||
label='C',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
enabled=False,
|
||||
mgmt_only=False,
|
||||
mtu=300,
|
||||
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||
mac_address='00-00-00-00-00-03',
|
||||
description='Third',
|
||||
vrf=vrfs[2],
|
||||
speed=100000,
|
||||
duplex='half',
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
name='Interface 4',
|
||||
label='D',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
enabled=True,
|
||||
mgmt_only=True,
|
||||
tx_power=40,
|
||||
speed=100000,
|
||||
duplex='full',
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
name='Interface 5',
|
||||
label='E',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
enabled=True,
|
||||
mgmt_only=True,
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
name='Interface 6',
|
||||
label='F',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
enabled=False,
|
||||
mgmt_only=False,
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
name='Interface 7',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
|
||||
rf_channel_frequency=2412,
|
||||
rf_channel_width=22
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
name='Interface 8',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
|
||||
rf_channel_frequency=5160,
|
||||
rf_channel_width=20
|
||||
),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
# 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):
|
||||
@@ -2601,6 +2708,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'mgmt_only': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_poe_mode(self):
|
||||
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_poe_type(self):
|
||||
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_mode(self):
|
||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
@@ -2827,8 +2942,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):
|
||||
@@ -2973,8 +3088,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):
|
||||
@@ -3558,6 +3673,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'),
|
||||
@@ -3565,24 +3695,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)
|
||||
|
||||
@@ -3606,13 +3729,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']}
|
||||
@@ -3654,6 +3777,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]}
|
||||
@@ -3675,7 +3805,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),
|
||||
@@ -3819,8 +3952,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']}
|
||||
|
||||
@@ -5,6 +5,7 @@ from circuits.models import *
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from tenancy.models import Tenant
|
||||
from utilities.utils import drange
|
||||
|
||||
|
||||
class LocationTestCase(TestCase):
|
||||
@@ -74,148 +75,142 @@ class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.site1 = Site.objects.create(
|
||||
name='TestSite1',
|
||||
slug='test-site-1'
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
self.site2 = Site.objects.create(
|
||||
name='TestSite2',
|
||||
slug='test-site-2'
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
)
|
||||
self.location1 = Location.objects.create(
|
||||
name='TestGroup1',
|
||||
slug='test-group-1',
|
||||
site=self.site1
|
||||
)
|
||||
self.location2 = Location.objects.create(
|
||||
name='TestGroup2',
|
||||
slug='test-group-2',
|
||||
site=self.site2
|
||||
)
|
||||
self.rack = Rack.objects.create(
|
||||
name='TestRack1',
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
Rack.objects.create(
|
||||
name='Rack 1',
|
||||
facility_id='A101',
|
||||
site=self.site1,
|
||||
location=self.location1,
|
||||
site=sites[0],
|
||||
location=locations[0],
|
||||
u_height=42
|
||||
)
|
||||
self.manufacturer = Manufacturer.objects.create(
|
||||
name='Acme',
|
||||
slug='acme'
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
self.device_type = {
|
||||
'ff2048': DeviceType.objects.create(
|
||||
manufacturer=self.manufacturer,
|
||||
model='FrameForwarder 2048',
|
||||
slug='ff2048'
|
||||
),
|
||||
'cc5000': DeviceType.objects.create(
|
||||
manufacturer=self.manufacturer,
|
||||
model='CurrentCatapult 5000',
|
||||
slug='cc5000',
|
||||
u_height=0
|
||||
),
|
||||
}
|
||||
self.role = {
|
||||
'Server': DeviceRole.objects.create(
|
||||
name='Server',
|
||||
slug='server',
|
||||
),
|
||||
'Switch': DeviceRole.objects.create(
|
||||
name='Switch',
|
||||
slug='switch',
|
||||
),
|
||||
'Console Server': DeviceRole.objects.create(
|
||||
name='Console Server',
|
||||
slug='console-server',
|
||||
),
|
||||
'PDU': DeviceRole.objects.create(
|
||||
name='PDU',
|
||||
slug='pdu',
|
||||
),
|
||||
|
||||
}
|
||||
DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
def test_rack_device_outside_height(self):
|
||||
|
||||
rack1 = Rack(
|
||||
name='TestRack2',
|
||||
facility_id='A102',
|
||||
site=self.site1,
|
||||
u_height=42
|
||||
)
|
||||
rack1.save()
|
||||
site = Site.objects.first()
|
||||
rack = Rack.objects.first()
|
||||
|
||||
device1 = Device(
|
||||
name='TestSwitch1',
|
||||
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
||||
device_role=DeviceRole.objects.get(slug='switch'),
|
||||
site=self.site1,
|
||||
rack=rack1,
|
||||
name='Device 1',
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
site=site,
|
||||
rack=rack,
|
||||
position=43,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
)
|
||||
device1.save()
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
rack1.clean()
|
||||
rack.clean()
|
||||
|
||||
def test_location_site(self):
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
location2 = Location.objects.get(name='Location 2')
|
||||
|
||||
rack_invalid_location = Rack(
|
||||
name='TestRack2',
|
||||
facility_id='A102',
|
||||
site=self.site1,
|
||||
u_height=42,
|
||||
location=self.location2
|
||||
rack2 = Rack(
|
||||
name='Rack 2',
|
||||
site=site1,
|
||||
location=location2,
|
||||
u_height=42
|
||||
)
|
||||
rack_invalid_location.save()
|
||||
rack2.save()
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
rack_invalid_location.clean()
|
||||
rack2.clean()
|
||||
|
||||
def test_mount_single_device(self):
|
||||
site = Site.objects.first()
|
||||
rack = Rack.objects.first()
|
||||
|
||||
device1 = Device(
|
||||
name='TestSwitch1',
|
||||
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
||||
device_role=DeviceRole.objects.get(slug='switch'),
|
||||
site=self.site1,
|
||||
rack=self.rack,
|
||||
position=10,
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
site=site,
|
||||
rack=rack,
|
||||
position=10.0,
|
||||
face=DeviceFaceChoices.FACE_REAR,
|
||||
)
|
||||
device1.save()
|
||||
|
||||
# Validate rack height
|
||||
self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
|
||||
self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5)))
|
||||
|
||||
# Validate inventory (front face)
|
||||
rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
|
||||
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
|
||||
del rack1_inventory_front[-10]
|
||||
for u in rack1_inventory_front:
|
||||
rack1_inventory_front = {
|
||||
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
|
||||
}
|
||||
self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
|
||||
self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
|
||||
del rack1_inventory_front[10.0]
|
||||
del rack1_inventory_front[10.5]
|
||||
for u in rack1_inventory_front.values():
|
||||
self.assertIsNone(u['device'])
|
||||
|
||||
# Validate inventory (rear face)
|
||||
rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
|
||||
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
|
||||
del rack1_inventory_rear[-10]
|
||||
for u in rack1_inventory_rear:
|
||||
rack1_inventory_rear = {
|
||||
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
|
||||
}
|
||||
self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
|
||||
self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
|
||||
del rack1_inventory_rear[10.0]
|
||||
del rack1_inventory_rear[10.5]
|
||||
for u in rack1_inventory_rear.values():
|
||||
self.assertIsNone(u['device'])
|
||||
|
||||
def test_mount_zero_ru(self):
|
||||
pdu = Device.objects.create(
|
||||
name='TestPDU',
|
||||
device_role=self.role.get('PDU'),
|
||||
device_type=self.device_type.get('cc5000'),
|
||||
site=self.site1,
|
||||
rack=self.rack,
|
||||
position=None,
|
||||
face='',
|
||||
)
|
||||
self.assertTrue(pdu)
|
||||
"""
|
||||
Check that a 0RU device can be mounted in a rack with no face/position.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
rack = Rack.objects.first()
|
||||
|
||||
Device(
|
||||
name='Device 1',
|
||||
device_role=DeviceRole.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
site=site,
|
||||
rack=rack
|
||||
).save()
|
||||
|
||||
def test_mount_half_u_devices(self):
|
||||
"""
|
||||
Check that two 0.5U devices can be mounted in the same rack unit.
|
||||
"""
|
||||
rack = Rack.objects.first()
|
||||
attrs = {
|
||||
'device_type': DeviceType.objects.get(u_height=0.5),
|
||||
'device_role': DeviceRole.objects.first(),
|
||||
'site': Site.objects.first(),
|
||||
'rack': rack,
|
||||
'face': DeviceFaceChoices.FACE_FRONT,
|
||||
}
|
||||
|
||||
Device(name='Device 1', position=1, **attrs).save()
|
||||
Device(name='Device 2', position=1.5, **attrs).save()
|
||||
|
||||
self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3)
|
||||
|
||||
def test_change_rack_site(self):
|
||||
"""
|
||||
@@ -224,19 +219,16 @@ class RackTestCase(TestCase):
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Device Role 1', slug='device-role-1', color='ff0000'
|
||||
)
|
||||
|
||||
# Create Rack1 in Site A
|
||||
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
|
||||
|
||||
# Create Device1 in Rack1
|
||||
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
|
||||
device1 = Device.objects.create(
|
||||
site=site_a,
|
||||
rack=rack1,
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first()
|
||||
)
|
||||
|
||||
# Move Rack1 to Site B
|
||||
rack1.site = site_b
|
||||
@@ -465,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')
|
||||
@@ -501,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):
|
||||
"""
|
||||
@@ -518,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()
|
||||
|
||||
@@ -569,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()
|
||||
|
||||
@@ -616,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()
|
||||
|
||||
@@ -12,6 +12,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.api.serializers import GenericObjectSerializer
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
@@ -175,9 +176,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=site, tenant=tenant),
|
||||
Location(name='Location 2', slug='location-2', site=site, tenant=tenant),
|
||||
Location(name='Location 3', slug='location-3', site=site, tenant=tenant),
|
||||
Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||
Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||
Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
@@ -188,16 +189,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'name': 'Location X',
|
||||
'slug': 'location-x',
|
||||
'site': site.pk,
|
||||
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||
'tenant': tenant.pk,
|
||||
'description': 'A new location',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"site,tenant,name,slug,description",
|
||||
"Site 1,Tenant 1,Location 4,location-4,Fourth location",
|
||||
"Site 1,Tenant 1,Location 5,location-5,Fifth location",
|
||||
"Site 1,Tenant 1,Location 6,location-6,Sixth location",
|
||||
"site,tenant,name,slug,status,description",
|
||||
"Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
|
||||
"Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
|
||||
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1960,7 +1962,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)
|
||||
@@ -2016,7 +2018,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)
|
||||
@@ -2078,7 +2080,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)
|
||||
@@ -2143,7 +2145,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)
|
||||
@@ -2204,6 +2206,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'description': 'A front port',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'tx_power': 10,
|
||||
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
|
||||
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||
@@ -2225,6 +2229,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'duplex': 'half',
|
||||
'mgmt_only': True,
|
||||
'description': 'A front port',
|
||||
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
|
||||
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||
@@ -2244,6 +2250,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'duplex': 'full',
|
||||
'mgmt_only': True,
|
||||
'description': 'New description',
|
||||
'poe_mode': InterfacePoEModeChoices.MODE_PD,
|
||||
'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'tx_power': 10,
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
@@ -2252,16 +2260,16 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
f"device,name,type,vrf.pk",
|
||||
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
|
||||
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
|
||||
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
|
||||
f"device,name,type,vrf.pk,poe_mode,poe_type",
|
||||
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
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)
|
||||
@@ -2332,7 +2340,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)
|
||||
@@ -2390,7 +2398,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)
|
||||
@@ -2623,19 +2631,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',
|
||||
@@ -2661,6 +2668,17 @@ class CableTestCase(
|
||||
'length_unit': CableLengthUnitChoices.UNIT_METER,
|
||||
}
|
||||
|
||||
def model_to_dict(self, *args, **kwargs):
|
||||
data = super().model_to_dict(*args, **kwargs)
|
||||
|
||||
# Serialize termination objects
|
||||
if 'a_terminations' in data:
|
||||
data['a_terminations'] = [obj.pk for obj in data['a_terminations']]
|
||||
if 'b_terminations' in data:
|
||||
data['b_terminations'] = [obj.pk for obj in data['b_terminations']]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = VirtualChassis
|
||||
@@ -2857,7 +2875,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)
|
||||
|
||||
@@ -295,7 +295,6 @@ urlpatterns = [
|
||||
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
|
||||
path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
|
||||
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
|
||||
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
|
||||
# Console server ports
|
||||
@@ -311,7 +310,6 @@ urlpatterns = [
|
||||
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
|
||||
path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
|
||||
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
|
||||
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
|
||||
|
||||
# Power ports
|
||||
@@ -327,7 +325,6 @@ urlpatterns = [
|
||||
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
|
||||
path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
|
||||
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
|
||||
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
|
||||
# Power outlets
|
||||
@@ -343,7 +340,6 @@ urlpatterns = [
|
||||
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
|
||||
path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
|
||||
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
|
||||
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
|
||||
|
||||
# Interfaces
|
||||
@@ -359,7 +355,6 @@ urlpatterns = [
|
||||
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
|
||||
path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
|
||||
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
|
||||
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||
|
||||
# Front ports
|
||||
@@ -375,7 +370,6 @@ urlpatterns = [
|
||||
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
|
||||
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
|
||||
path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
|
||||
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
|
||||
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
|
||||
|
||||
# Rear ports
|
||||
@@ -391,7 +385,6 @@ urlpatterns = [
|
||||
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
|
||||
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
|
||||
path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
|
||||
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
|
||||
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
|
||||
|
||||
# Module bays
|
||||
@@ -448,6 +441,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'),
|
||||
@@ -501,6 +495,5 @@ urlpatterns = [
|
||||
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import itertools
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
|
||||
@@ -22,34 +24,37 @@ def object_to_path_node(obj):
|
||||
|
||||
def path_node_to_object(repr):
|
||||
"""
|
||||
Given the string representation of a path node, return the corresponding instance.
|
||||
Given the string representation of a path node, return the corresponding instance. If the object no longer
|
||||
exists, return None.
|
||||
"""
|
||||
ct_id, object_id = decompile_path_node(repr)
|
||||
ct = ContentType.objects.get_for_id(ct_id)
|
||||
return ct.model_class().objects.get(pk=object_id)
|
||||
return ct.model_class().objects.filter(pk=object_id).first()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
@@ -12,7 +10,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 +26,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()
|
||||
@@ -312,7 +322,7 @@ class SiteListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class SiteView(generic.ObjectView):
|
||||
queryset = Site.objects.prefetch_related('region', 'tenant__group')
|
||||
queryset = Site.objects.prefetch_related('tenant__group')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
stats = {
|
||||
@@ -347,7 +357,7 @@ class SiteView(generic.ObjectView):
|
||||
site=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
|
||||
asn_count = asns.count()
|
||||
@@ -379,14 +389,14 @@ class SiteBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class SiteBulkEditView(generic.BulkEditView):
|
||||
queryset = Site.objects.prefetch_related('region', 'tenant')
|
||||
queryset = Site.objects.all()
|
||||
filterset = filtersets.SiteFilterSet
|
||||
table = tables.SiteTable
|
||||
form = forms.SiteBulkEditForm
|
||||
|
||||
|
||||
class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Site.objects.prefetch_related('region', 'tenant')
|
||||
queryset = Site.objects.all()
|
||||
filterset = filtersets.SiteFilterSet
|
||||
table = tables.SiteTable
|
||||
|
||||
@@ -442,7 +452,7 @@ class LocationView(generic.ObjectView):
|
||||
location=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
return {
|
||||
'rack_count': rack_count,
|
||||
@@ -561,7 +571,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RackListView(generic.ObjectListView):
|
||||
required_prerequisites = [Site]
|
||||
queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
|
||||
queryset = Rack.objects.annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filtersets.RackFilterSet
|
||||
@@ -620,7 +630,7 @@ class RackView(generic.ObjectView):
|
||||
rack=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||
|
||||
@@ -638,6 +648,11 @@ class RackView(generic.ObjectView):
|
||||
|
||||
device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count()
|
||||
|
||||
# Determine any additional parameters to pass when embedding the rack elevations
|
||||
svg_extra = '&'.join([
|
||||
f'highlight=id:{pk}' for pk in request.GET.getlist('device')
|
||||
])
|
||||
|
||||
return {
|
||||
'device_count': device_count,
|
||||
'reservations': reservations,
|
||||
@@ -645,6 +660,7 @@ class RackView(generic.ObjectView):
|
||||
'nonracked_devices': nonracked_devices,
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
}
|
||||
|
||||
|
||||
@@ -665,14 +681,14 @@ class RackBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class RackBulkEditView(generic.BulkEditView):
|
||||
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
|
||||
queryset = Rack.objects.all()
|
||||
filterset = filtersets.RackFilterSet
|
||||
table = tables.RackTable
|
||||
form = forms.RackBulkEditForm
|
||||
|
||||
|
||||
class RackBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
|
||||
queryset = Rack.objects.all()
|
||||
filterset = filtersets.RackFilterSet
|
||||
table = tables.RackTable
|
||||
|
||||
@@ -689,7 +705,7 @@ class RackReservationListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class RackReservationView(generic.ObjectView):
|
||||
queryset = RackReservation.objects.prefetch_related('rack')
|
||||
queryset = RackReservation.objects.all()
|
||||
|
||||
|
||||
class RackReservationEditView(generic.ObjectEditView):
|
||||
@@ -725,14 +741,14 @@ class RackReservationImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class RackReservationBulkEditView(generic.BulkEditView):
|
||||
queryset = RackReservation.objects.prefetch_related('rack', 'user')
|
||||
queryset = RackReservation.objects.all()
|
||||
filterset = filtersets.RackReservationFilterSet
|
||||
table = tables.RackReservationTable
|
||||
form = forms.RackReservationBulkEditForm
|
||||
|
||||
|
||||
class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackReservation.objects.prefetch_related('rack', 'user')
|
||||
queryset = RackReservation.objects.all()
|
||||
filterset = filtersets.RackReservationFilterSet
|
||||
table = tables.RackReservationTable
|
||||
|
||||
@@ -814,7 +830,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceTypeListView(generic.ObjectListView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = DeviceType.objects.annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filtersets.DeviceTypeFilterSet
|
||||
@@ -823,7 +839,7 @@ class DeviceTypeListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class DeviceTypeView(generic.ObjectView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer')
|
||||
queryset = DeviceType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
|
||||
@@ -928,18 +944,18 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
]
|
||||
queryset = DeviceType.objects.all()
|
||||
model_form = forms.DeviceTypeImportForm
|
||||
related_object_forms = OrderedDict((
|
||||
('console-ports', forms.ConsolePortTemplateImportForm),
|
||||
('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
|
||||
('power-ports', forms.PowerPortTemplateImportForm),
|
||||
('power-outlets', forms.PowerOutletTemplateImportForm),
|
||||
('interfaces', forms.InterfaceTemplateImportForm),
|
||||
('rear-ports', forms.RearPortTemplateImportForm),
|
||||
('front-ports', forms.FrontPortTemplateImportForm),
|
||||
('module-bays', forms.ModuleBayTemplateImportForm),
|
||||
('device-bays', forms.DeviceBayTemplateImportForm),
|
||||
('inventory-items', forms.InventoryItemTemplateImportForm),
|
||||
))
|
||||
related_object_forms = {
|
||||
'console-ports': forms.ConsolePortTemplateImportForm,
|
||||
'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
|
||||
'power-ports': forms.PowerPortTemplateImportForm,
|
||||
'power-outlets': forms.PowerOutletTemplateImportForm,
|
||||
'interfaces': forms.InterfaceTemplateImportForm,
|
||||
'rear-ports': forms.RearPortTemplateImportForm,
|
||||
'front-ports': forms.FrontPortTemplateImportForm,
|
||||
'module-bays': forms.ModuleBayTemplateImportForm,
|
||||
'device-bays': forms.DeviceBayTemplateImportForm,
|
||||
'inventory-items': forms.InventoryItemTemplateImportForm,
|
||||
}
|
||||
|
||||
def prep_related_object_data(self, parent, data):
|
||||
data.update({'device_type': parent})
|
||||
@@ -947,7 +963,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
|
||||
|
||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = DeviceType.objects.annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filtersets.DeviceTypeFilterSet
|
||||
@@ -956,7 +972,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = DeviceType.objects.annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filtersets.DeviceTypeFilterSet
|
||||
@@ -968,7 +984,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class ModuleTypeListView(generic.ObjectListView):
|
||||
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = ModuleType.objects.annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
)
|
||||
filterset = filtersets.ModuleTypeFilterSet
|
||||
@@ -977,7 +993,7 @@ class ModuleTypeListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class ModuleTypeView(generic.ObjectView):
|
||||
queryset = ModuleType.objects.prefetch_related('manufacturer')
|
||||
queryset = ModuleType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
|
||||
@@ -1058,15 +1074,15 @@ class ModuleTypeImportView(generic.ObjectImportView):
|
||||
]
|
||||
queryset = ModuleType.objects.all()
|
||||
model_form = forms.ModuleTypeImportForm
|
||||
related_object_forms = OrderedDict((
|
||||
('console-ports', forms.ConsolePortTemplateImportForm),
|
||||
('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
|
||||
('power-ports', forms.PowerPortTemplateImportForm),
|
||||
('power-outlets', forms.PowerOutletTemplateImportForm),
|
||||
('interfaces', forms.InterfaceTemplateImportForm),
|
||||
('rear-ports', forms.RearPortTemplateImportForm),
|
||||
('front-ports', forms.FrontPortTemplateImportForm),
|
||||
))
|
||||
related_object_forms = {
|
||||
'console-ports': forms.ConsolePortTemplateImportForm,
|
||||
'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
|
||||
'power-ports': forms.PowerPortTemplateImportForm,
|
||||
'power-outlets': forms.PowerOutletTemplateImportForm,
|
||||
'interfaces': forms.InterfaceTemplateImportForm,
|
||||
'rear-ports': forms.RearPortTemplateImportForm,
|
||||
'front-ports': forms.FrontPortTemplateImportForm,
|
||||
}
|
||||
|
||||
def prep_related_object_data(self, parent, data):
|
||||
data.update({'module_type': parent})
|
||||
@@ -1074,7 +1090,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
|
||||
|
||||
|
||||
class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = ModuleType.objects.annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
)
|
||||
filterset = filtersets.ModuleTypeFilterSet
|
||||
@@ -1083,7 +1099,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
|
||||
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
queryset = ModuleType.objects.annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
)
|
||||
filterset = filtersets.ModuleTypeFilterSet
|
||||
@@ -1594,9 +1610,7 @@ class DeviceListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class DeviceView(generic.ObjectView):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
|
||||
)
|
||||
queryset = Device.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# VirtualChassis members
|
||||
@@ -1710,7 +1724,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
|
||||
)
|
||||
@@ -1773,14 +1787,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class DeviceBulkEditView(generic.BulkEditView):
|
||||
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
form = forms.DeviceBulkEditForm
|
||||
|
||||
|
||||
class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
@@ -1796,7 +1810,7 @@ class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
#
|
||||
|
||||
class ModuleListView(generic.ObjectListView):
|
||||
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
|
||||
queryset = Module.objects.prefetch_related('module_type__manufacturer')
|
||||
filterset = filtersets.ModuleFilterSet
|
||||
filterset_form = forms.ModuleFilterForm
|
||||
table = tables.ModuleTable
|
||||
@@ -1822,14 +1836,14 @@ class ModuleBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class ModuleBulkEditView(generic.BulkEditView):
|
||||
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
|
||||
queryset = Module.objects.prefetch_related('module_type__manufacturer')
|
||||
filterset = filtersets.ModuleFilterSet
|
||||
table = tables.ModuleTable
|
||||
form = forms.ModuleBulkEditForm
|
||||
|
||||
|
||||
class ModuleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
|
||||
queryset = Module.objects.prefetch_related('module_type__manufacturer')
|
||||
filterset = filtersets.ModuleFilterSet
|
||||
table = tables.ModuleTable
|
||||
|
||||
@@ -2555,7 +2569,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class InventoryItemBulkEditView(generic.BulkEditView):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
|
||||
queryset = InventoryItem.objects.all()
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
table = tables.InventoryItemTable
|
||||
form = forms.InventoryItemBulkEditForm
|
||||
@@ -2566,7 +2580,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
|
||||
|
||||
|
||||
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
|
||||
queryset = InventoryItem.objects.all()
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
@@ -2744,7 +2758,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 +2794,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 +2815,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 +2827,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_b 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 +2871,20 @@ class CableBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class CableBulkEditView(generic.BulkEditView):
|
||||
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
|
||||
'terminations___site',
|
||||
)
|
||||
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__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
|
||||
'terminations___site',
|
||||
)
|
||||
filterset = filtersets.CableFilterSet
|
||||
table = tables.CableTable
|
||||
|
||||
@@ -2956,7 +2940,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
#
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
queryset = VirtualChassis.objects.annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
table = tables.VirtualChassisTable
|
||||
@@ -3183,9 +3167,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class PowerPanelListView(generic.ObjectListView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'location'
|
||||
).annotate(
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filtersets.PowerPanelFilterSet
|
||||
@@ -3194,10 +3176,10 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class PowerPanelView(generic.ObjectView):
|
||||
queryset = PowerPanel.objects.prefetch_related('site', 'location')
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
|
||||
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance)
|
||||
powerfeed_table = tables.PowerFeedTable(
|
||||
data=power_feeds,
|
||||
orderable=False
|
||||
@@ -3227,16 +3209,14 @@ class PowerPanelBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class PowerPanelBulkEditView(generic.BulkEditView):
|
||||
queryset = PowerPanel.objects.prefetch_related('site', 'location')
|
||||
queryset = PowerPanel.objects.all()
|
||||
filterset = filtersets.PowerPanelFilterSet
|
||||
table = tables.PowerPanelTable
|
||||
form = forms.PowerPanelBulkEditForm
|
||||
|
||||
|
||||
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'location'
|
||||
).annotate(
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filtersets.PowerPanelFilterSet
|
||||
@@ -3255,7 +3235,7 @@ class PowerFeedListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class PowerFeedView(generic.ObjectView):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
|
||||
queryset = PowerFeed.objects.all()
|
||||
|
||||
|
||||
class PowerFeedEditView(generic.ObjectEditView):
|
||||
@@ -3274,7 +3254,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class PowerFeedBulkEditView(generic.BulkEditView):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
|
||||
queryset = PowerFeed.objects.all()
|
||||
filterset = filtersets.PowerFeedFilterSet
|
||||
table = tables.PowerFeedTable
|
||||
form = forms.PowerFeedBulkEditForm
|
||||
@@ -3285,6 +3265,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
|
||||
|
||||
|
||||
class PowerFeedBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
|
||||
queryset = PowerFeed.objects.all()
|
||||
filterset = filtersets.PowerFeedFilterSet
|
||||
table = tables.PowerFeedTable
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras import choices, models
|
||||
from netbox.api import ChoiceField, WritableNestedSerializer
|
||||
from netbox.api.serializers import NestedTagSerializer
|
||||
from netbox.api.fields import ChoiceField
|
||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -5,15 +5,15 @@ from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
|
||||
NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
)
|
||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
@@ -85,13 +85,14 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
data_type = serializers.SerializerMethodField()
|
||||
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description',
|
||||
'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex', 'choices', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
|
||||
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def get_data_type(self, obj):
|
||||
@@ -272,6 +273,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
locations = SerializedPKRelatedField(
|
||||
queryset=Location.objects.all(),
|
||||
serializer=NestedLocationSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
device_types = SerializedPKRelatedField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
serializer=NestedDeviceTypeSerializer,
|
||||
@@ -331,8 +338,8 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
model = ConfigContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
||||
'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data', 'created', 'last_updated',
|
||||
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from netbox.api import NetBoxRouter
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
||||
|
||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
||||
queryset = ConfigContext.objects.prefetch_related(
|
||||
'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
||||
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
||||
)
|
||||
serializer_class = serializers.ConfigContextSerializer
|
||||
filterset_class = filtersets.ConfigContextFilterSet
|
||||
|
||||
@@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldVisibilityChoices(ChoiceSet):
|
||||
|
||||
VISIBILITY_READ_WRITE = 'read-write'
|
||||
VISIBILITY_READ_ONLY = 'read-only'
|
||||
VISIBILITY_HIDDEN = 'hidden'
|
||||
|
||||
CHOICES = (
|
||||
(VISIBILITY_READ_WRITE, 'Read/Write'),
|
||||
(VISIBILITY_READ_ONLY, 'Read-only'),
|
||||
(VISIBILITY_HIDDEN, 'Hidden'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# CustomLinks
|
||||
#
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||
@@ -71,7 +71,10 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'name', 'required', 'filter_logic', 'weight', 'description']
|
||||
fields = [
|
||||
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
|
||||
'description',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -79,6 +82,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(label__icontains=value) |
|
||||
Q(group_name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
@@ -260,6 +264,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='locations',
|
||||
queryset=Location.objects.all(),
|
||||
label='Location',
|
||||
)
|
||||
location = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='locations__slug',
|
||||
queryset=Location.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Location (slug)',
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_types',
|
||||
queryset=DeviceType.objects.all(),
|
||||
|
||||
@@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
queryset=CustomField.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
group_name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
@@ -34,8 +37,15 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
label="UI visibility",
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
nullable_fields = ('group_name', 'description',)
|
||||
|
||||
|
||||
class CustomLinkBulkEditForm(BulkEditForm):
|
||||
|
||||
@@ -42,9 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
||||
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex',
|
||||
'validation_regex', 'ui_visibility',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from extras.models import *
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
@@ -18,6 +19,7 @@ class CustomFieldsMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.custom_fields = {}
|
||||
self.custom_field_groups = {}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -42,8 +44,21 @@ class CustomFieldsMixin:
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
self.fields[field_name].disabled = True
|
||||
if self.fields[field_name].help_text:
|
||||
self.fields[field_name].help_text += '<br />'
|
||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
||||
'Field is set to read-only.'
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[field_name] = customfield
|
||||
if customfield.group_name not in self.custom_field_groups:
|
||||
self.custom_field_groups[customfield.group_name] = []
|
||||
self.custom_field_groups[customfield.group_name].append(field_name)
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
@@ -32,7 +32,7 @@ __all__ = (
|
||||
class CustomFieldFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
('Attributes', ('type', 'content_type_id', 'weight', 'required')),
|
||||
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -45,6 +45,9 @@ class CustomFieldFilterForm(FilterForm):
|
||||
required=False,
|
||||
label=_('Field type')
|
||||
)
|
||||
group_name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
@@ -54,6 +57,12 @@ class CustomFieldFilterForm(FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
required=False,
|
||||
label=_('UI visibility'),
|
||||
widget=StaticSelect()
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterForm(FilterForm):
|
||||
@@ -163,7 +172,7 @@ class TagFilterForm(FilterForm):
|
||||
class ConfigContextFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id'))
|
||||
@@ -183,6 +192,11 @@ class ConfigContextFilterForm(FilterForm):
|
||||
required=False,
|
||||
label=_('Sites')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Locations')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
@@ -40,8 +40,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
|
||||
('Behavior', ('filter_logic',)),
|
||||
('Custom Field', (
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
|
||||
)),
|
||||
('Behavior', ('filter_logic', 'ui_visibility')),
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
@@ -56,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
widgets = {
|
||||
'type': StaticSelect(),
|
||||
'filter_logic': StaticSelect(),
|
||||
'ui_visibility': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +167,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
)
|
||||
locations = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False
|
||||
)
|
||||
device_types = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False
|
||||
@@ -200,15 +207,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
data = JSONField(
|
||||
label=''
|
||||
data = JSONField()
|
||||
|
||||
fieldsets = (
|
||||
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
||||
('Assignment', (
|
||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags',
|
||||
)
|
||||
|
||||
|
||||
|
||||
27
netbox/extras/migrations/0074_customfield_extensions.py
Normal file
27
netbox/extras/migrations/0074_customfield_extensions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.0.4 on 2022-04-15 17:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0073_journalentry_tags_custom_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='customfield',
|
||||
options={'ordering': ['group_name', 'weight', 'name']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='group_name',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='ui_visibility',
|
||||
field=models.CharField(default='read-write', max_length=50),
|
||||
),
|
||||
]
|
||||
19
netbox/extras/migrations/0075_configcontext_locations.py
Normal file
19
netbox/extras/migrations/0075_configcontext_locations.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-22 19:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0156_location_status'),
|
||||
('extras', '0074_customfield_extensions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='locations',
|
||||
field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'),
|
||||
),
|
||||
]
|
||||
18
netbox/extras/migrations/0076_tag_slug_unicode.py
Normal file
18
netbox/extras/migrations/0076_tag_slug_unicode.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.6 on 2022-07-14 15:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0075_configcontext_locations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tag',
|
||||
name='slug',
|
||||
field=models.SlugField(allow_unicode=True, max_length=100, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0076_tag_slug_unicode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customlink',
|
||||
name='link_text',
|
||||
field=models.TextField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customlink',
|
||||
name='link_url',
|
||||
field=models.TextField(),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,3 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
related_name='+',
|
||||
blank=True
|
||||
)
|
||||
locations = models.ManyToManyField(
|
||||
to='dcim.Location',
|
||||
related_name='+',
|
||||
blank=True
|
||||
)
|
||||
device_types = models.ManyToManyField(
|
||||
to='dcim.DeviceType',
|
||||
related_name='+',
|
||||
@@ -138,11 +141,10 @@ class ConfigContextModel(models.Model):
|
||||
|
||||
def get_config_context(self):
|
||||
"""
|
||||
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
|
||||
Return the rendered configuration context for a device or VM.
|
||||
"""
|
||||
|
||||
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
|
||||
data = OrderedDict()
|
||||
data = {}
|
||||
|
||||
if not hasattr(self, 'config_context_data'):
|
||||
# The annotation is not available, so we fall back to manually querying for the config context objects
|
||||
|
||||
@@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
help_text='Name of the field as displayed to users (if not provided, '
|
||||
'the field\'s name will be used)'
|
||||
)
|
||||
group_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Custom fields within the same group will be displayed together"
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
@@ -131,10 +136,17 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
null=True,
|
||||
help_text='Comma-separated list of available choices (for selection fields)'
|
||||
)
|
||||
ui_visibility = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldVisibilityChoices,
|
||||
default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
|
||||
verbose_name='UI visibility',
|
||||
help_text='Specifies the visibility of custom field in the UI'
|
||||
)
|
||||
objects = CustomFieldManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
|
||||
@@ -204,12 +204,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
link_text = models.CharField(
|
||||
max_length=500,
|
||||
link_text = models.TextField(
|
||||
help_text="Jinja2 template code for link text"
|
||||
)
|
||||
link_url = models.CharField(
|
||||
max_length=500,
|
||||
link_url = models.TextField(
|
||||
verbose_name='Link URL',
|
||||
help_text="Jinja2 template code for link URL"
|
||||
)
|
||||
|
||||
@@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
# `device_role` for Device; `role` for VirtualMachine
|
||||
role = getattr(obj, 'device_role', None) or obj.role
|
||||
|
||||
# Device type assignment is relevant only for Devices
|
||||
# Device type and location assignment is relevant only for Devices
|
||||
device_type = getattr(obj, 'device_type', None)
|
||||
location = getattr(obj, 'location', None)
|
||||
|
||||
# Get assigned cluster, group, and type (if any)
|
||||
cluster = getattr(obj, 'cluster', None)
|
||||
@@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
Q(regions__in=regions) | Q(regions=None),
|
||||
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
||||
Q(sites=obj.site) | Q(sites=None),
|
||||
Q(locations=location) | Q(locations=None),
|
||||
Q(device_types=device_type) | Q(device_types=None),
|
||||
Q(roles=role) | Q(roles=None),
|
||||
Q(platforms=obj.platform) | Q(platforms=None),
|
||||
@@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
if self.model._meta.model_name == 'device':
|
||||
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
|
||||
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
||||
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||
|
||||
@@ -28,3 +28,4 @@ registry = Registry()
|
||||
registry['model_features'] = {
|
||||
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
|
||||
}
|
||||
registry['denormalized_fields'] = collections.defaultdict(list)
|
||||
|
||||
@@ -3,7 +3,6 @@ import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import traceback
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -114,7 +113,7 @@ class Report(object):
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._results = OrderedDict()
|
||||
self._results = {}
|
||||
self.active_test = None
|
||||
self.failed = False
|
||||
|
||||
@@ -125,13 +124,13 @@ class Report(object):
|
||||
for method in dir(self):
|
||||
if method.startswith('test_') and callable(getattr(self, method)):
|
||||
test_methods.append(method)
|
||||
self._results[method] = OrderedDict([
|
||||
('success', 0),
|
||||
('info', 0),
|
||||
('warning', 0),
|
||||
('failure', 0),
|
||||
('log', []),
|
||||
])
|
||||
self._results[method] = {
|
||||
'success': 0,
|
||||
'info': 0,
|
||||
'warning': 0,
|
||||
'failure': 0,
|
||||
'log': [],
|
||||
}
|
||||
if not test_methods:
|
||||
raise Exception("A report must contain at least one test method.")
|
||||
self.test_methods = test_methods
|
||||
|
||||
@@ -6,7 +6,6 @@ import pkgutil
|
||||
import sys
|
||||
import traceback
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
from django import forms
|
||||
@@ -496,7 +495,7 @@ def get_scripts(use_names=False):
|
||||
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
|
||||
defined name in place of the actual module name.
|
||||
"""
|
||||
scripts = OrderedDict()
|
||||
scripts = {}
|
||||
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
@@ -510,7 +509,7 @@ def get_scripts(use_names=False):
|
||||
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
module_scripts = {}
|
||||
script_order = getattr(module, "script_order", ())
|
||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
||||
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
|
||||
|
||||
@@ -28,14 +28,15 @@ class CustomFieldTable(NetBoxTable):
|
||||
)
|
||||
content_types = columns.ContentTypesColumn()
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'choices', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -166,8 +167,9 @@ class ConfigContextTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
|
||||
'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import template
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -50,7 +48,7 @@ def custom_links(context, obj):
|
||||
'perms': context['perms'], # django.contrib.auth.context_processors.auth
|
||||
}
|
||||
template_code = ''
|
||||
group_names = OrderedDict()
|
||||
group_names = {}
|
||||
|
||||
for cl in custom_links:
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ from django.test import TestCase
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||
from extras.choices import (
|
||||
CustomFieldTypeChoices, CustomFieldFilterLogicChoices, JournalEntryKindChoices, ObjectChangeActionChoices,
|
||||
)
|
||||
from dcim.models import Location
|
||||
from extras.choices import *
|
||||
from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from ipam.models import IPAddress
|
||||
@@ -32,21 +31,24 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
required=True,
|
||||
weight=100,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE,
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
|
||||
),
|
||||
CustomField(
|
||||
name='Custom Field 2',
|
||||
type=CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
required=False,
|
||||
weight=200,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY
|
||||
),
|
||||
CustomField(
|
||||
name='Custom Field 3',
|
||||
type=CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
required=False,
|
||||
weight=300,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
||||
),
|
||||
)
|
||||
CustomField.objects.bulk_create(custom_fields)
|
||||
@@ -76,6 +78,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_ui_visibility(self):
|
||||
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = Webhook.objects.all()
|
||||
@@ -431,9 +437,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
Region(name='Region 2', slug='region-2'),
|
||||
Region(name='Region 3', slug='region-3'),
|
||||
)
|
||||
for r in regions:
|
||||
r.save()
|
||||
@@ -447,12 +453,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
site_group.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1'),
|
||||
Site(name='Test Site 2', slug='test-site-2'),
|
||||
Site(name='Test Site 3', slug='test-site-3'),
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||
)
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
@@ -523,6 +537,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
c.regions.set([regions[i]])
|
||||
c.site_groups.set([site_groups[i]])
|
||||
c.sites.set([sites[i]])
|
||||
c.locations.set([locations[i]])
|
||||
c.device_types.set([device_types[i]])
|
||||
c.roles.set([device_roles[i]])
|
||||
c.platforms.set([platforms[i]])
|
||||
@@ -564,6 +579,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
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(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device_type(self):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -29,7 +29,8 @@ class ConfigContextTest(TestCase):
|
||||
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
self.region = Region.objects.create(name="Region")
|
||||
self.sitegroup = SiteGroup.objects.create(name="Site Group")
|
||||
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup)
|
||||
self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
|
||||
self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
|
||||
self.platform = Platform.objects.create(name="Platform")
|
||||
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
|
||||
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
|
||||
@@ -40,7 +41,8 @@ class ConfigContextTest(TestCase):
|
||||
name='Device 1',
|
||||
device_type=self.devicetype,
|
||||
device_role=self.devicerole,
|
||||
site=self.site
|
||||
site=self.site,
|
||||
location=self.location
|
||||
)
|
||||
|
||||
def test_higher_weight_wins(self):
|
||||
@@ -144,15 +146,6 @@ class ConfigContextTest(TestCase):
|
||||
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_annotation_same_as_get_for_object_device_relations(self):
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={
|
||||
"site": 1
|
||||
}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
weight=100,
|
||||
@@ -169,6 +162,22 @@ class ConfigContextTest(TestCase):
|
||||
}
|
||||
)
|
||||
sitegroup_context.site_groups.add(self.sitegroup)
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={
|
||||
"site": 1
|
||||
}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
location_context = ConfigContext.objects.create(
|
||||
name="location",
|
||||
weight=100,
|
||||
data={
|
||||
"location": 1
|
||||
}
|
||||
)
|
||||
location_context.locations.add(self.location)
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
@@ -205,6 +214,7 @@ class ConfigContextTest(TestCase):
|
||||
device = Device.objects.create(
|
||||
name="Device 2",
|
||||
site=self.site,
|
||||
location=self.location,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
@@ -220,13 +230,6 @@ class ConfigContextTest(TestCase):
|
||||
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
||||
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={"site": 1}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
weight=100,
|
||||
@@ -241,6 +244,13 @@ class ConfigContextTest(TestCase):
|
||||
)
|
||||
sitegroup_context.site_groups.add(self.sitegroup)
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={"site": 1}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
|
||||
@@ -36,14 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'default': None,
|
||||
'weight': 200,
|
||||
'required': True,
|
||||
'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,',
|
||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
||||
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
|
||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
|
||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView):
|
||||
('Regions', instance.regions.all),
|
||||
('Site Groups', instance.site_groups.all),
|
||||
('Sites', instance.sites.all),
|
||||
('Locations', instance.locations.all),
|
||||
('Device Types', instance.device_types.all),
|
||||
('Roles', instance.roles.all),
|
||||
('Platforms', instance.platforms.all),
|
||||
@@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView):
|
||||
class ConfigContextEditView(generic.ObjectEditView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
form = forms.ConfigContextForm
|
||||
template_name = 'extras/configcontext_edit.html'
|
||||
|
||||
|
||||
class ConfigContextBulkEditView(generic.BulkEditView):
|
||||
@@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
|
||||
class JournalEntryBulkEditView(generic.BulkEditView):
|
||||
queryset = JournalEntry.objects.prefetch_related('created_by')
|
||||
queryset = JournalEntry.objects.all()
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
table = tables.JournalEntryTable
|
||||
form = forms.JournalEntryBulkEditForm
|
||||
|
||||
|
||||
class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JournalEntry.objects.prefetch_related('created_by')
|
||||
queryset = JournalEntry.objects.all()
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
@@ -27,10 +26,18 @@ def serialize_for_webhook(instance):
|
||||
|
||||
|
||||
def get_snapshots(instance, action):
|
||||
return {
|
||||
snapshots = {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
|
||||
'postchange': None,
|
||||
}
|
||||
if action != ObjectChangeActionChoices.ACTION_DELETE:
|
||||
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
|
||||
if hasattr(instance, 'serialize_object'):
|
||||
snapshots['postchange'] = instance.serialize_object()
|
||||
else:
|
||||
snapshots['postchange'] = serialize_object(instance)
|
||||
|
||||
return snapshots
|
||||
|
||||
|
||||
def generate_signature(request_body, secret):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user