Merge pull request #7611 from netbox-community/3979-wireless

Closes #3979: Wireless network modeling
This commit is contained in:
Jeremy Stretch 2021-10-21 15:09:08 -04:00 committed by GitHub
commit 334c97035e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 3366 additions and 212 deletions

View File

@ -0,0 +1,8 @@
# Wireless Networks
{!models/wireless/wirelesslan.md!}
{!models/wireless/wirelesslangroup.md!}
---
{!models/wireless/wirelesslink.md!}

View File

@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
### Wireless Interfaces
Wireless interfaces may additionally track the following attributes:
* **Role** - AP or station
* **Channel** - One of several standard wireless channels
* **Channel Frequency** - The transmit frequency
* **Channel Width** - Channel bandwidth
If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually.
### IP Address Assignment ### IP Address Assignment
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)

View File

@ -0,0 +1,11 @@
# Wireless LANs
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
Each wireless LAN may have authentication attributes associated with it, including:
* Authentication type
* Cipher
* Pre-shared key

View File

@ -0,0 +1,3 @@
# Wireless LAN Groups
Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group.

View File

@ -0,0 +1,9 @@
# Wireless Links
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
Each wireless link may have authentication attributes associated with it, including:
* Authentication type
* Cipher
* Pre-shared key

View File

@ -60,6 +60,7 @@ nav:
- Virtualization: 'core-functionality/virtualization.md' - Virtualization: 'core-functionality/virtualization.md'
- Service Mapping: 'core-functionality/services.md' - Service Mapping: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md' - Circuits: 'core-functionality/circuits.md'
- Wireless: 'core-functionality/wireless.md'
- Power Tracking: 'core-functionality/power.md' - Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md' - Tenancy: 'core-functionality/tenancy.md'
- Contacts: 'core-functionality/contacts.md' - Contacts: 'core-functionality/contacts.md'

View File

@ -3,7 +3,7 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField from netbox.api import ChoiceField
from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -88,7 +88,7 @@ class CircuitSerializer(PrimaryModelSerializer):
] ]
class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer): class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
@ -99,6 +99,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied', '_occupied',
] ]

View File

@ -0,0 +1,21 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('circuits', '0003_extend_tag_support'),
]
operations = [
migrations.RenameField(
model_name='circuittermination',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='circuittermination',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
]

View File

@ -4,7 +4,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import CableTermination, PathEndpoint from dcim.models import LinkTermination, PathEndpoint
from extras.models import ObjectChange from extras.models import ObjectChange
from extras.utils import extras_features from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
@ -256,7 +256,7 @@ class Circuit(PrimaryModel):
@extras_features('webhooks') @extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, CableTermination): class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -17,28 +17,29 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedClusterSerializer from virtualization.api.nested_serializers import NestedClusterSerializer
from wireless.choices import *
from .nested_serializers import * from .nested_serializers import *
class CableTerminationSerializer(serializers.ModelSerializer): class LinkTerminationSerializer(serializers.ModelSerializer):
cable_peer_type = serializers.SerializerMethodField(read_only=True) link_peer_type = serializers.SerializerMethodField(read_only=True)
cable_peer = serializers.SerializerMethodField(read_only=True) link_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True)
def get_cable_peer_type(self, obj): def get_link_peer_type(self, obj):
if obj._cable_peer is not None: if obj._link_peer is not None:
return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
return None return None
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_cable_peer(self, obj): def get_link_peer(self, obj):
""" """
Return the appropriate serializer for the cable termination model. Return the appropriate serializer for the link termination model.
""" """
if obj._cable_peer is not None: if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
context = {'request': self.context['request']} context = {'request': self.context['request']}
return serializer(obj._cable_peer, context=context).data return serializer(obj._link_peer, context=context).data
return None return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField) @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
@ -503,7 +504,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components # Device components
# #
class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -522,12 +523,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -546,12 +547,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = ConsolePort model = ConsolePort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -575,12 +576,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -594,18 +595,20 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerPort model = PowerPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True) parent = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -620,10 +623,10 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'_occupied', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied',
] ]
def validate(self, data): def validate(self, data):
@ -640,7 +643,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
return super().validate(data) return super().validate(data)
class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -650,7 +653,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = RearPort model = RearPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'last_updated', '_occupied',
] ]
@ -666,7 +669,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label'] fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -677,7 +680,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
model = FrontPort model = FrontPort
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
@ -728,7 +731,7 @@ class CableSerializer(PrimaryModelSerializer):
) )
termination_a = serializers.SerializerMethodField(read_only=True) termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@ -853,7 +856,7 @@ class PowerPanelSerializer(PrimaryModelSerializer):
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer() power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer( rack = NestedRackSerializer(
@ -883,7 +886,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
model = PowerFeed model = PowerFeed
fields = [ fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]

View File

@ -513,7 +513,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
# #
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet filterset_class = filtersets.ConsolePortFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
@ -521,7 +521,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related( queryset = ConsoleServerPort.objects.prefetch_related(
'device', '_path__destination', 'cable', '_cable_peer', 'tags' 'device', '_path__destination', 'cable', '_link_peer', 'tags'
) )
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet filterset_class = filtersets.ConsoleServerPortFilterSet
@ -529,14 +529,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
class PowerPortViewSet(PathEndpointMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet filterset_class = filtersets.PowerPortFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet filterset_class = filtersets.PowerOutletFilterSet
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags'
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
@ -625,7 +625,7 @@ class PowerPanelViewSet(ModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related( queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
) )
serializer_class = serializers.PowerFeedSerializer serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet filterset_class = filtersets.PowerFeedFilterSet

View File

@ -1061,7 +1061,7 @@ class PortTypeChoices(ChoiceSet):
# #
# Cables # Cables/links
# #
class CableTypeChoices(ChoiceSet): class CableTypeChoices(ChoiceSet):
@ -1125,7 +1125,7 @@ class CableTypeChoices(ChoiceSet):
) )
class CableStatusChoices(ChoiceSet): class LinkStatusChoices(ChoiceSet):
STATUS_CONNECTED = 'connected' STATUS_CONNECTED = 'connected'
STATUS_PLANNED = 'planned' STATUS_PLANNED = 'planned'

View File

@ -42,6 +42,7 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211N, InterfaceTypeChoices.TYPE_80211N,
InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AD,
InterfaceTypeChoices.TYPE_80211AX,
] ]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

@ -14,6 +14,7 @@ from utilities.filters import (
TreeNodeMultipleChoiceFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import * from .models import *
@ -987,10 +988,19 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
null_value=None null_value=None
) )
rf_role = django_filters.MultipleChoiceFilter(
choices=WirelessRoleChoices
)
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
class Meta: class Meta:
model = Interface model = Interface
fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] fields = [
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'description',
]
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: try:
@ -1202,7 +1212,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
choices=CableTypeChoices choices=CableTypeChoices
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=CableStatusChoices choices=LinkStatusChoices
) )
color = django_filters.MultipleChoiceFilter( color = django_filters.MultipleChoiceFilter(
choices=ColorChoices choices=ColorChoices

View File

@ -463,7 +463,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect() widget=StaticSelect()
) )
status = forms.ChoiceField( status = forms.ChoiceField(
choices=add_blank_choice(CableStatusChoices), choices=add_blank_choice(LinkStatusChoices),
required=False, required=False,
widget=StaticSelect(), widget=StaticSelect(),
initial='' initial=''
@ -940,7 +940,7 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm( class InterfaceBulkEditForm(
form_from_model(Interface, [ form_from_model(Interface, [
'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
'mode', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
]), ]),
BootstrapMixin, BootstrapMixin,
AddRemoveTagsForm, AddRemoveTagsForm,
@ -991,8 +991,8 @@ class InterfaceBulkEditForm(
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan', 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
'tagged_vlans', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
__all__ = ( __all__ = (
'CableCSVForm', 'CableCSVForm',
@ -584,12 +585,18 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
required=False, required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)' help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
) )
rf_role = CSVChoiceField(
choices=WirelessRoleChoices,
required=False,
help_text='Wireless role (AP/station)'
)
class Meta: class Meta:
model = Interface model = Interface
fields = ( fields = (
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn',
'mtu', 'mgmt_only', 'description', 'mode', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -812,7 +819,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
# Cable attributes # Cable attributes
status = CSVChoiceField( status = CSVChoiceField(
choices=CableStatusChoices, choices=LinkStatusChoices,
required=False, required=False,
help_text='Connection status' help_text='Connection status'
) )

View File

@ -11,6 +11,7 @@ from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from wireless.choices import *
__all__ = ( __all__ = (
'CableFilterForm', 'CableFilterForm',
@ -735,7 +736,7 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
) )
status = forms.ChoiceField( status = forms.ChoiceField(
required=False, required=False,
choices=add_blank_choice(CableStatusChoices), choices=add_blank_choice(LinkStatusChoices),
widget=StaticSelect() widget=StaticSelect()
) )
color = ColorField( color = ColorField(
@ -966,6 +967,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
['rf_role', 'rf_channel', 'rf_channel_width'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
] ]
kind = forms.MultipleChoiceField( kind = forms.MultipleChoiceField(
@ -998,6 +1000,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False, required=False,
label='WWN' label='WWN'
) )
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,
widget=StaticSelectMultiple(),
label='Wireless role'
)
rf_channel = forms.MultipleChoiceField(
choices=WirelessChannelChoices,
required=False,
widget=StaticSelectMultiple(),
label='Wireless channel'
)
rf_channel_frequency = forms.IntegerField(
required=False,
label='Channel frequency (MHz)'
)
rf_channel_width = forms.IntegerField(
required=False,
label='Channel width (MHz)'
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -16,6 +16,7 @@ from utilities.forms import (
SlugField, StaticSelect, SlugField, StaticSelect,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm from .common import InterfaceCommonForm
__all__ = ( __all__ = (
@ -1100,6 +1101,19 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
'type': 'lag', 'type': 'lag',
} }
) )
wireless_lan_group = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
label='Wireless LAN group'
)
wireless_lans = DynamicModelMultipleChoiceField(
queryset=WirelessLAN.objects.all(),
required=False,
label='Wireless LANs',
query_params={
'group_id': '$wireless_lan_group',
}
)
vlan_group = DynamicModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
@ -1130,18 +1144,23 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
model = Interface model = Interface
fields = [ fields = [
'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(), 'type': StaticSelect(),
'mode': StaticSelect(), 'mode': StaticSelect(),
'rf_role': StaticSelect(),
'rf_channel': StaticSelect(),
} }
labels = { labels = {
'mode': '802.1Q Mode', 'mode': '802.1Q Mode',
} }
help_texts = { help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT, 'mode': INTERFACE_MODE_HELP_TEXT,
'rf_channel_frequency': "Populated by selected channel (if set)",
'rf_channel_width': "Populated by selected channel (if set)",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -10,6 +10,7 @@ from utilities.forms import (
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, StaticSelect, ExpandableNameField, StaticSelect,
) )
from wireless.choices import *
from .common import InterfaceCommonForm from .common import InterfaceCommonForm
__all__ = ( __all__ = (
@ -465,7 +466,27 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
mode = forms.ChoiceField( mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices), choices=add_blank_choice(InterfaceModeChoices),
required=False, required=False,
widget=StaticSelect()
)
rf_role = forms.ChoiceField(
choices=add_blank_choice(WirelessRoleChoices),
required=False,
widget=StaticSelect(), widget=StaticSelect(),
label='Wireless role'
)
rf_channel = forms.ChoiceField(
choices=add_blank_choice(WirelessChannelChoices),
required=False,
widget=StaticSelect(),
label='Wireless channel'
)
rf_channel_frequency = forms.DecimalField(
required=False,
label='Channel frequency (MHz)'
)
rf_channel_width = forms.DecimalField(
required=False,
label='Channel width (MHz)'
) )
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -477,7 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
) )
field_order = ( field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -212,6 +212,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info): def resolve_mode(self, info):
return self.mode or None return self.mode or None
def resolve_rf_role(self, info):
return self.rf_role or None
def resolve_rf_channel(self, info):
return self.rf_channel or None
class InterfaceTemplateType(ComponentTemplateObjectType): class InterfaceTemplateType(ComponentTemplateObjectType):

View File

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management.color import no_style from django.core.management.color import no_style
from django.db import connection from django.db import connection
from django.db.models import Q
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath from dcim.signals import create_cablepath
@ -67,7 +68,10 @@ class Command(BaseCommand):
# Retrace paths # Retrace paths
for model in ENDPOINT_MODELS: for model in ENDPOINT_MODELS:
origins = model.objects.filter(cable__isnull=False) params = Q(cable__isnull=False)
if hasattr(model, 'wireless_link'):
params |= Q(wireless_link__isnull=False)
origins = model.objects.filter(params)
if not options['force']: if not options['force']:
origins = origins.filter(_path__isnull=True) origins = origins.filter(_path__isnull=True)
origins_count = origins.count() origins_count = origins.count()

View File

@ -0,0 +1,91 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0138_extend_tag_support'),
]
operations = [
migrations.RenameField(
model_name='consoleport',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='consoleport',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='consoleserverport',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='consoleserverport',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='frontport',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='frontport',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='interface',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='interface',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='powerfeed',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='powerfeed',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='poweroutlet',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='poweroutlet',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='powerport',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='powerport',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
migrations.RenameField(
model_name='rearport',
old_name='_cable_peer_id',
new_name='_link_peer_id',
),
migrations.RenameField(
model_name='rearport',
old_name='_cable_peer_type',
new_name='_link_peer_type',
),
]

View File

@ -0,0 +1,43 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0139_rename_cable_peer'),
('wireless', '0001_wireless'),
]
operations = [
migrations.AddField(
model_name='interface',
name='rf_role',
field=models.CharField(blank=True, max_length=30),
),
migrations.AddField(
model_name='interface',
name='rf_channel',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interface',
name='rf_channel_frequency',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True),
),
migrations.AddField(
model_name='interface',
name='rf_channel_width',
field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True),
),
migrations.AddField(
model_name='interface',
name='wireless_lans',
field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
),
migrations.AddField(
model_name='interface',
name='wireless_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'),
),
]

View File

@ -10,7 +10,7 @@ __all__ = (
'BaseInterface', 'BaseInterface',
'Cable', 'Cable',
'CablePath', 'CablePath',
'CableTermination', 'LinkTermination',
'ConsolePort', 'ConsolePort',
'ConsolePortTemplate', 'ConsolePortTemplate',
'ConsoleServerPort', 'ConsoleServerPort',

View File

@ -64,8 +64,8 @@ class Cable(PrimaryModel):
) )
status = models.CharField( status = models.CharField(
max_length=50, max_length=50,
choices=CableStatusChoices, choices=LinkStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED default=LinkStatusChoices.STATUS_CONNECTED
) )
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
@ -292,7 +292,7 @@ class Cable(PrimaryModel):
self._pk = self.pk self._pk = self.pk
def get_status_class(self): def get_status_class(self):
return CableStatusChoices.CSS_CLASSES.get(self.status) return LinkStatusChoices.CSS_CLASSES.get(self.status)
def get_compatible_types(self): def get_compatible_types(self):
""" """
@ -386,7 +386,7 @@ class CablePath(BigIDModel):
""" """
from circuits.models import CircuitTermination from circuits.models import CircuitTermination
if origin is None or origin.cable is None: if origin is None or origin.link is None:
return None return None
destination = None destination = None
@ -396,13 +396,13 @@ class CablePath(BigIDModel):
is_split = False is_split = False
node = origin node = origin
while node.cable is not None: while node.link is not None:
if node.cable.status != CableStatusChoices.STATUS_CONNECTED: if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False is_active = False
# Follow the cable to its far-end termination # Follow the link to its far-end termination
path.append(object_to_path_node(node.cable)) path.append(object_to_path_node(node.link))
peer_termination = node.get_cable_peer() peer_termination = node.get_link_peer()
# Follow a FrontPort to its corresponding RearPort # Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort): if isinstance(peer_termination, FrontPort):

View File

@ -18,11 +18,13 @@ from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from wireless.choices import *
from wireless.utils import get_channel_attr
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'CableTermination', 'LinkTermination',
'ConsolePort', 'ConsolePort',
'ConsoleServerPort', 'ConsoleServerPort',
'DeviceBay', 'DeviceBay',
@ -87,14 +89,14 @@ class ComponentModel(PrimaryModel):
return self.device return self.device
class CableTermination(models.Model): class LinkTermination(models.Model):
""" """
An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance. include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
reference the attached Cable or WirelessLink instance, respectively.
`_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in shortcut to referencing `instance.link.termination_b`, for example.
dcim.signals when a Cable instance is created or deleted, respectively.
""" """
cable = models.ForeignKey( cable = models.ForeignKey(
to='dcim.Cable', to='dcim.Cable',
@ -103,20 +105,20 @@ class CableTermination(models.Model):
blank=True, blank=True,
null=True null=True
) )
_cable_peer_type = models.ForeignKey( _link_peer_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
blank=True, blank=True,
null=True null=True
) )
_cable_peer_id = models.PositiveIntegerField( _link_peer_id = models.PositiveIntegerField(
blank=True, blank=True,
null=True null=True
) )
_cable_peer = GenericForeignKey( _link_peer = GenericForeignKey(
ct_field='_cable_peer_type', ct_field='_link_peer_type',
fk_field='_cable_peer_id' fk_field='_link_peer_id'
) )
mark_connected = models.BooleanField( mark_connected = models.BooleanField(
default=False, default=False,
@ -146,8 +148,8 @@ class CableTermination(models.Model):
"mark_connected": "Cannot mark as connected with a cable attached." "mark_connected": "Cannot mark as connected with a cable attached."
}) })
def get_cable_peer(self): def get_link_peer(self):
return self._cable_peer return self._link_peer
@property @property
def _occupied(self): def _occupied(self):
@ -157,6 +159,13 @@ class CableTermination(models.Model):
def parent_object(self): def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()") raise NotImplementedError("CableTermination models must implement parent_object()")
@property
def link(self):
"""
Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
"""
return self.cable
class PathEndpoint(models.Model): class PathEndpoint(models.Model):
""" """
@ -219,7 +228,7 @@ class PathEndpoint(models.Model):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ComponentModel, CableTermination, PathEndpoint): class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
@ -251,7 +260,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
@ -283,7 +292,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ComponentModel, CableTermination, PathEndpoint): class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
@ -333,8 +342,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct, _link_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids _link_peer_id__in=outlet_ids
).aggregate( ).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
@ -347,12 +356,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
} }
# Calculate per-leg aggregates for three-phase feeds # Calculate per-leg aggregates for three-phase feeds
if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices: for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct, _link_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids _link_peer_id__in=outlet_ids
).aggregate( ).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
@ -380,7 +389,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -475,7 +484,7 @@ class BaseInterface(models.Model):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
""" """
@ -517,6 +526,45 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
verbose_name='WWN', verbose_name='WWN',
help_text='64-bit World Wide Name' help_text='64-bit World Wide Name'
) )
rf_role = models.CharField(
max_length=30,
choices=WirelessRoleChoices,
blank=True,
verbose_name='Wireless role'
)
rf_channel = models.CharField(
max_length=50,
choices=WirelessChannelChoices,
blank=True,
verbose_name='Wireless channel'
)
rf_channel_frequency = models.DecimalField(
max_digits=7,
decimal_places=2,
blank=True,
null=True,
verbose_name='Channel frequency (MHz)'
)
rf_channel_width = models.DecimalField(
max_digits=7,
decimal_places=3,
blank=True,
null=True,
verbose_name='Channel width (MHz)'
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
wireless_lans = models.ManyToManyField(
to='wireless.WirelessLAN',
related_name='interfaces',
blank=True,
verbose_name='Wireless LANs'
)
untagged_vlan = models.ForeignKey( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -550,14 +598,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def clean(self): def clean(self):
super().clean() super().clean()
# Virtual interfaces cannot be connected # Virtual Interfaces cannot have a Cable attached
if not self.is_connectable and self.cable: if self.is_virtual and self.cable:
raise ValidationError({ raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached." 'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
}) })
# Non-connectable interfaces cannot be marked as connected # Virtual Interfaces cannot be marked as connected
if not self.is_connectable and self.mark_connected: if self.is_virtual and self.mark_connected:
raise ValidationError({ raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
}) })
@ -603,6 +651,34 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
if self.pk and self.lag_id == self.pk: if self.pk and self.lag_id == self.pk:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
# RF role & channel may only be set for wireless interfaces
if self.rf_role and not self.is_wireless:
raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
if self.rf_channel and not self.is_wireless:
raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
# Validate channel frequency against interface type and selected channel (if any)
if self.rf_channel_frequency:
if not self.is_wireless:
raise ValidationError({
'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
})
if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
})
elif self.rf_channel:
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
if not self.is_wireless:
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
elif self.rf_channel:
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
# Validate untagged VLAN # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({ raise ValidationError({
@ -611,8 +687,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
}) })
@property @property
def is_connectable(self): def _occupied(self):
return self.type not in NONCONNECTABLE_IFACE_TYPES return super()._occupied or bool(self.wireless_link_id)
@property
def is_wired(self):
return not self.is_virtual and not self.is_wireless
@property @property
def is_virtual(self): def is_virtual(self):
@ -626,13 +706,17 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def is_lag(self): def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG return self.type == InterfaceTypeChoices.TYPE_LAG
@property
def link(self):
return self.cable or self.wireless_link
# #
# Pass-through ports # Pass-through ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ComponentModel, CableTermination): class FrontPort(ComponentModel, LinkTermination):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
""" """
@ -686,7 +770,7 @@ class FrontPort(ComponentModel, CableTermination):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RearPort(ComponentModel, CableTermination): class RearPort(ComponentModel, LinkTermination):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
""" """

View File

@ -10,7 +10,7 @@ from extras.utils import extras_features
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import CableTermination, PathEndpoint from .device_components import LinkTermination, PathEndpoint
__all__ = ( __all__ = (
'PowerFeed', 'PowerFeed',
@ -72,7 +72,7 @@ class PowerPanel(PrimaryModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
""" """
An electrical circuit delivered from a PowerPanel. An electrical circuit delivered from a PowerPanel.
""" """

View File

@ -427,13 +427,13 @@ class Rack(PrimaryModel):
return 0 return 0
pf_powerports = PowerPort.objects.filter( pf_powerports = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerFeed), _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
_cable_peer_id__in=powerfeeds.values_list('id', flat=True) _link_peer_id__in=powerfeeds.values_list('id', flat=True)
) )
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter( allocated_draw_total = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerOutlet), _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_cable_peer_id__in=poweroutlets.values_list('id', flat=True) _link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
return int(allocated_draw_total / available_power_total * 100) return int(allocated_draw_total / available_power_total * 100)

View File

@ -2,37 +2,11 @@ import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableStatusChoices from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .utils import create_cablepath, rebuild_paths
def create_cablepath(node):
"""
Create CablePaths for all paths originating from the specified node.
"""
cp = CablePath.from_origin(node)
if cp:
try:
cp.save()
except Exception as e:
print(node, node.pk)
raise e
def rebuild_paths(obj):
"""
Rebuild all CablePaths which traverse the specified node
"""
cable_paths = CablePath.objects.filter(path__contains=obj)
with transaction.atomic():
for cp in cable_paths:
cp.delete()
if cp.origin:
create_cablepath(cp.origin)
# #
@ -109,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if instance.termination_a.cable != instance: if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}") logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance instance.termination_a.cable = instance
instance.termination_a._cable_peer = instance.termination_b instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save() instance.termination_a.save()
if instance.termination_b.cable != instance: if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}") logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance instance.termination_b.cable = instance
instance.termination_b._cable_peer = instance.termination_a instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save() instance.termination_b.save()
# Create/update cable paths # Create/update cable paths
@ -128,7 +102,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
# We currently don't support modifying either termination of an existing Cable. (This # 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 # may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly. # any CablePaths accordingly.
if instance.status != CableStatusChoices.STATUS_CONNECTED: if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(path__contains=instance).update(is_active=False) CablePath.objects.filter(path__contains=instance).update(is_active=False)
else: else:
rebuild_paths(instance) rebuild_paths(instance)
@ -145,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs):
if instance.termination_a is not None: if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}") logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model model = instance.termination_a._meta.model
model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None) model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None: if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}") logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model model = instance.termination_b._meta.model
model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None) model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths # Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance): for cablepath in CablePath.objects.filter(path__contains=instance):

View File

@ -398,6 +398,39 @@ class CableTraceSVG:
return group 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): def _draw_attachment(self):
""" """
Return an SVG group containing a line element and "Attachment" label. Return an SVG group containing a line element and "Attachment" label.
@ -418,6 +451,9 @@ class CableTraceSVG:
""" """
Return an SVG document representing a cable trace. Return an SVG document representing a cable trace.
""" """
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace() traced_path = self.origin.trace()
# Prep elements list # Prep elements list
@ -452,24 +488,39 @@ class CableTraceSVG:
) )
terminations.append(termination) terminations.append(termination)
# Connector (either a Cable or attachment to a ProviderNetwork) # Connector (a Cable or WirelessLink)
if connector is not None: if connector is not None:
# Cable # Cable
cable_labels = [ if type(connector) is Cable:
f'Cable {connector}', connector_labels = [
connector.get_status_display() f'Cable {connector}',
] connector.get_status_display()
if connector.type: ]
cable_labels.append(connector.get_type_display()) if connector.type:
if connector.length and connector.length_unit: connector_labels.append(connector.get_type_display())
cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}') if connector.length and connector.length_unit:
cable = self._draw_cable( connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
color=connector.color or '000000', cable = self._draw_cable(
url=connector.get_absolute_url(), color=connector.color or '000000',
labels=cable_labels url=connector.get_absolute_url(),
) labels=connector_labels
connectors.append(cable) )
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 # Far end termination
termination = self._draw_box( termination = self._draw_box(

View File

@ -11,11 +11,7 @@ from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
) )
from .template_code import ( from .template_code import *
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
POWERPORT_BUTTONS, REARPORT_BUTTONS,
)
__all__ = ( __all__ = (
'BaseInterfaceTable', 'BaseInterfaceTable',
@ -266,11 +262,11 @@ class CableTerminationTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Cable Color' verbose_name='Cable Color'
) )
cable_peer = TemplateColumn( link_peer = TemplateColumn(
accessor='_cable_peer', accessor='_link_peer',
template_code=CABLETERMINATION, template_code=LINKTERMINATION,
orderable=False, orderable=False,
verbose_name='Cable Peer' verbose_name='Link Peer'
) )
mark_connected = BooleanColumn() mark_connected = BooleanColumn()
@ -278,7 +274,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable): class PathEndpointTable(CableTerminationTable):
connection = TemplateColumn( connection = TemplateColumn(
accessor='_path.last_node', accessor='_path.last_node',
template_code=CABLETERMINATION, template_code=LINKTERMINATION,
verbose_name='Connection', verbose_name='Connection',
orderable=False orderable=False
) )
@ -299,7 +295,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'link_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -320,7 +316,7 @@ class DeviceConsolePortTable(ConsolePortTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions' 'link_peer', 'connection', 'tags', 'actions'
) )
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -343,7 +339,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'link_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -365,7 +361,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions', 'link_peer', 'connection', 'tags', 'actions',
) )
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -388,7 +384,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', 'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -410,7 +406,7 @@ class DevicePowerPortTable(PowerPortTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@ -439,7 +435,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', 'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'cable_color', 'link_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -460,7 +456,7 @@ class DevicePowerOutletTable(PowerOutletTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@ -493,6 +489,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
} }
) )
mgmt_only = BooleanColumn() mgmt_only = BooleanColumn()
wireless_link = tables.Column(
linkify=True
)
wireless_lans = TemplateColumn(
template_code=INTERFACE_WIRELESS_LANS,
orderable=False,
verbose_name='Wireless LANs'
)
tags = TagColumn( tags = TagColumn(
url_name='dcim:interface_list' url_name='dcim:interface_list'
) )
@ -501,7 +505,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
model = Interface model = Interface
fields = ( fields = (
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected',
'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'untagged_vlan', 'tagged_vlans',
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -509,8 +514,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
class DeviceInterfaceTable(InterfaceTable): class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant' template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet' '{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
@ -533,8 +538,9 @@ class DeviceInterfaceTable(InterfaceTable):
model = Interface model = Interface
fields = ( fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color',
'untagged_vlan', 'tagged_vlans', 'actions', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
'tagged_vlans', 'actions',
) )
order_by = ('name',) order_by = ('name',)
default_columns = ( default_columns = (
@ -570,7 +576,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -594,10 +600,10 @@ class DeviceFrontPortTable(FrontPortTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags', 'actions', 'cable_color', 'link_peer', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
'actions', 'actions',
) )
row_attrs = { row_attrs = {
@ -621,7 +627,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags', 'cable_color', 'link_peer', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -643,10 +649,10 @@ class DeviceRearPortTable(RearPortTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'tags', 'actions', 'link_peer', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
) )
row_attrs = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class

View File

@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
'cable_peer', 'link_peer',
) )

View File

@ -1,4 +1,4 @@
CABLETERMINATION = """ LINKTERMINATION = """
{% if value %} {% if value %}
{% if value.parent_object %} {% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a> <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """
{% endif %} {% endif %}
""" """
INTERFACE_WIRELESS_LANS = """
{% for wlan in record.wireless_lans.all %}
<a href="{{ wlan.get_absolute_url }}">{{ wlan }}</a><br />
{% endfor %}
"""
POWERFEED_CABLE = """ POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a> <a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"> <a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
@ -195,15 +201,23 @@ INTERFACE_BUTTONS = """
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if record.cable %} {% if record.link %}
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a> <a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% endif %}
{% if record.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %} {% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm"> <a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% elif record.is_connectable and perms.dcim.add_cable %} {% elif record.wireless_link %}
{% if perms.wireless.delete_wirelesslink %}
<a href="{% url 'wireless:wirelesslink_delete' pk=record.wireless_link.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Delete wireless link" class="btn btn-danger btn-sm">
<i class="mdi mdi-wifi-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif record.is_wired and perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %} {% if not record.mark_connected %}
@ -221,6 +235,10 @@ INTERFACE_BUTTONS = """
{% else %} {% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a> <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %} {% 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">
<span class="mdi mdi-wifi-plus" aria-hidden="true"></span>
</a>
{% endif %} {% endif %}
""" """

View File

@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from circuits.models import * from circuits.models import *
from dcim.choices import CableStatusChoices from dcim.choices import LinkStatusChoices
from dcim.models import * from dcim.models import *
from dcim.utils import object_to_path_node from dcim.utils import object_to_path_node
@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase):
self.assertEqual(CablePath.objects.count(), 2) self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "planned" # Change cable 2's status to "planned"
cable2.status = CableStatusChoices.STATUS_PLANNED cable2.status = LinkStatusChoices.STATUS_PLANNED
cable2.save() cable2.save()
self.assertPathExists( self.assertPathExists(
origin=interface1, origin=interface1,
@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase):
# Change cable 2's status to "connected" # Change cable 2's status to "connected"
cable2 = Cable.objects.get(pk=cable2.pk) cable2 = Cable.objects.get(pk=cable2.pk)
cable2.status = CableStatusChoices.STATUS_CONNECTED cable2.status = LinkStatusChoices.STATUS_CONNECTED
cable2.save() cable2.save()
self.assertPathExists( self.assertPathExists(
origin=interface1, origin=interface1,

View File

@ -9,6 +9,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests from utilities.testing import ChangeLoggedFilterSetTests
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -2063,6 +2064,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False),
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) Interface.objects.bulk_create(interfaces)
@ -2083,11 +2086,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'connected': True} params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False} params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_enabled(self): def test_enabled(self):
params = {'enabled': 'true'} params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'enabled': 'false'} params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2099,7 +2102,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'true'} params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'mgmt_only': 'false'} params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self): def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS} params = {'mode': InterfaceModeChoices.MODE_ACCESS}
@ -2176,7 +2179,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cabled': 'true'} params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'} params = {'cabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_kind(self): def test_kind(self):
params = {'kind': 'physical'} params = {'kind': 'physical'}
@ -2192,6 +2195,22 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rf_role(self):
params = {'rf_role': [WirelessRoleChoices.ROLE_AP, WirelessRoleChoices.ROLE_STATION]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rf_channel(self):
params = {'rf_channel': [WirelessChannelChoices.CHANNEL_24G_1, WirelessChannelChoices.CHANNEL_5G_32]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rf_channel_frequency(self):
params = {'rf_channel_frequency': [2412, 5160]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rf_channel_width(self):
params = {'rf_channel_width': [22, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
@ -2864,12 +2883,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables # Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() 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=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, 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=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, 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=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, 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=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).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=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, 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(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self): def test_label(self):
@ -2889,9 +2908,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_status(self): def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]} params = {'status': [LinkStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'status': [CableStatusChoices.STATUS_PLANNED]} params = {'status': [LinkStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_color(self): def test_color(self):

View File

@ -494,9 +494,9 @@ class CableTestCase(TestCase):
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1) self.assertEqual(self.cable.termination_a, interface1)
self.assertEqual(interface1._cable_peer, interface2) self.assertEqual(interface1._link_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2) self.assertEqual(self.cable.termination_b, interface2)
self.assertEqual(interface2._cable_peer, interface1) self.assertEqual(interface2._link_peer, interface1)
def test_cable_deletion(self): def test_cable_deletion(self):
""" """
@ -508,10 +508,10 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None') self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable) self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._cable_peer) self.assertIsNone(interface1._link_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable) self.assertIsNone(interface2.cable)
self.assertIsNone(interface2._cable_peer) self.assertIsNone(interface2._link_peer)
def test_cabletermination_deletion(self): def test_cabletermination_deletion(self):
""" """

View File

@ -1944,7 +1944,7 @@ class CableTestCase(
'termination_b_type': interface_ct.pk, 'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk, 'termination_b_id': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6, 'type': CableTypeChoices.TYPE_CAT6,
'status': CableStatusChoices.STATUS_PLANNED, 'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label', 'label': 'Label',
'color': 'c0c0c0', 'color': 'c0c0c0',
'length': 100, 'length': 100,
@ -1961,7 +1961,7 @@ class CableTestCase(
cls.bulk_edit_data = { cls.bulk_edit_data = {
'type': CableTypeChoices.TYPE_CAT5E, 'type': CableTypeChoices.TYPE_CAT5E,
'status': CableStatusChoices.STATUS_CONNECTED, 'status': LinkStatusChoices.STATUS_CONNECTED,
'label': 'New label', 'label': 'New label',
'color': '00ff00', 'color': '00ff00',
'length': 50, 'length': 50,

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction
def compile_path_node(ct_id, object_id): def compile_path_node(ct_id, object_id):
@ -26,3 +27,29 @@ def path_node_to_object(repr):
ct_id, object_id = decompile_path_node(repr) ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id) ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_id) return ct.model_class().objects.get(pk=object_id)
def create_cablepath(node):
"""
Create CablePaths for all paths originating from the specified node.
"""
from dcim.models import CablePath
cp = CablePath.from_origin(node)
if cp:
cp.save()
def rebuild_paths(obj):
"""
Rebuild all CablePaths which traverse the specified node
"""
from dcim.models import CablePath
cable_paths = CablePath.objects.filter(path__contains=obj)
with transaction.atomic():
for cp in cable_paths:
cp.delete()
if cp.origin:
create_cablepath(cp.origin)

View File

@ -308,6 +308,7 @@ class APIRootView(APIView):
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
('users', reverse('users-api:api-root', request=request, format=format)), ('users', reverse('users-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
('wireless', reverse('wireless-api:api-root', request=request, format=format)),
))) )))

View File

@ -7,6 +7,7 @@ from ipam.graphql.schema import IPAMQuery
from tenancy.graphql.schema import TenancyQuery from tenancy.graphql.schema import TenancyQuery
from users.graphql.schema import UsersQuery from users.graphql.schema import UsersQuery
from virtualization.graphql.schema import VirtualizationQuery from virtualization.graphql.schema import VirtualizationQuery
from wireless.graphql.schema import WirelessQuery
class Query( class Query(
@ -17,6 +18,7 @@ class Query(
TenancyQuery, TenancyQuery,
UsersQuery, UsersQuery,
VirtualizationQuery, VirtualizationQuery,
WirelessQuery,
graphene.ObjectType graphene.ObjectType
): ):
pass pass

View File

@ -176,6 +176,7 @@ CONNECTIONS_MENU = Menu(
label='Connections', label='Connections',
items=( items=(
get_model_item('dcim', 'cable', 'Cables', actions=['import']), get_model_item('dcim', 'cable', 'Cables', actions=['import']),
get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
MenuItem( MenuItem(
link='dcim:interface_connections_list', link='dcim:interface_connections_list',
link_text='Interface Connections', link_text='Interface Connections',
@ -196,6 +197,20 @@ CONNECTIONS_MENU = Menu(
), ),
) )
WIRELESS_MENU = Menu(
label='Wireless',
icon_class='mdi mdi-wifi',
groups=(
MenuGroup(
label='Wireless',
items=(
get_model_item('wireless', 'wirelesslan', 'Wireless LANs'),
get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'),
),
),
),
)
IPAM_MENU = Menu( IPAM_MENU = Menu(
label='IPAM', label='IPAM',
icon_class='mdi mdi-counter', icon_class='mdi mdi-counter',
@ -351,6 +366,7 @@ MENUS = [
ORGANIZATION_MENU, ORGANIZATION_MENU,
DEVICES_MENU, DEVICES_MENU,
CONNECTIONS_MENU, CONNECTIONS_MENU,
WIRELESS_MENU,
IPAM_MENU, IPAM_MENU,
VIRTUALIZATION_MENU, VIRTUALIZATION_MENU,
CIRCUITS_MENU, CIRCUITS_MENU,

View File

@ -326,6 +326,7 @@ INSTALLED_APPS = [
'users', 'users',
'utilities', 'utilities',
'virtualization', 'virtualization',
'wireless',
'django_rq', # Must come after extras to allow overriding management commands 'django_rq', # Must come after extras to allow overriding management commands
'drf_yasg', 'drf_yasg',
] ]

View File

@ -48,6 +48,7 @@ _patterns = [
path('tenancy/', include('tenancy.urls')), path('tenancy/', include('tenancy.urls')),
path('user/', include('users.urls')), path('user/', include('users.urls')),
path('virtualization/', include('virtualization.urls')), path('virtualization/', include('virtualization.urls')),
path('wireless/', include('wireless.urls')),
# API # API
path('api/', APIRootView.as_view(), name='api-root'), path('api/', APIRootView.as_view(), name='api-root'),
@ -58,6 +59,7 @@ _patterns = [
path('api/tenancy/', include('tenancy.api.urls')), path('api/tenancy/', include('tenancy.api.urls')),
path('api/users/', include('users.api.urls')), path('api/users/', include('users.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')), path('api/virtualization/', include('virtualization.api.urls')),
path('api/wireless/', include('wireless.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'), path('api/status/', StatusView.as_view(), name='api-status'),
path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),

View File

@ -27,6 +27,7 @@ from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
from netbox.forms import SearchForm from netbox.forms import SearchForm
from tenancy.models import Tenant from tenancy.models import Tenant
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink
class HomeView(View): class HomeView(View):
@ -92,14 +93,19 @@ class HomeView(View):
("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
) )
wireless = (
("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
)
sections = ( sections = (
("Organization", org, "domain"), ("Organization", org, "domain"),
("IPAM", ipam, "counter"), ("IPAM", ipam, "counter"),
("Virtualization", virtualization, "monitor"), ("Virtualization", virtualization, "monitor"),
("Inventory", dcim, "server"), ("Inventory", dcim, "server"),
("Connections", connections, "cable-data"),
("Circuits", circuits, "transit-connection-variant"), ("Circuits", circuits, "transit-connection-variant"),
("Connections", connections, "cable-data"),
("Power", power, "flash"), ("Power", power, "flash"),
("Wireless", wireless, "wifi"),
) )
stats = [] stats = []

Binary file not shown.

View File

@ -59,8 +59,13 @@ svg {
stroke: var(--nbx-trace-cable-shadow); stroke: var(--nbx-trace-cable-shadow);
stroke-width: 7px; stroke-width: 7px;
} }
line.wireless-link {
stroke: var(--nbx-trace-attachment);
stroke-dasharray: 4px 12px;
stroke-linecap: round;
}
line.attachment { line.attachment {
stroke: var(--nbx-trace-attachment); stroke: var(--nbx-trace-attachment);
stroke-dasharray: 5px, 5px; stroke-dasharray: 5px;
} }
} }

View File

@ -45,7 +45,7 @@
<span class="text-muted">Marked as connected</span> <span class="text-muted">Marked as connected</span>
{% elif termination.cable %} {% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> <a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
{% with peer=termination.get_cable_peer %} {% with peer=termination.get_link_peer %}
to to
{% if peer.device %} {% if peer.device %}
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a><br/> <a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a><br/>

View File

@ -107,7 +107,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% if object.is_connectable %} {% if not object.is_virtual %}
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Connection Connection
@ -211,10 +211,40 @@
</td> </td>
</tr> </tr>
</table> </table>
{% elif object.wireless_link %}
<table class="table table-hover">
<tr>
<th scope="row">Wireless Link</th>
<td>
<a href="{{ object.wireless_link.get_absolute_url }}">{{ object.wireless_link }}</a>
<a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td>
</tr>
{% with peer_interface=object.connected_endpoint %}
<tr>
<th scope="row">Device</th>
<td>
<a href="{{ peer_interface.device.get_absolute_url }}">{{ peer_interface.device }}</a>
</td>
</tr>
<tr>
<th scope="row">Name</th>
<td>
<a href="{{ peer_interface.get_absolute_url }}">{{ peer_interface }}</a>
</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>{{ peer_interface.get_type_display }}</td>
</tr>
{% endwith %}
</table>
{% else %} {% else %}
<div class="text-muted"> <div class="text-muted">
Not Connected Not Connected
{% if perms.dcim.add_cable %} {% if object.is_wired and perms.dcim.add_cable %}
<div class="dropdown float-end"> <div class="dropdown float-end">
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
@ -242,12 +272,125 @@
</li> </li>
</ul> </ul>
</div> </div>
{% elif object.is_wireless and perms.wireless.add_wirelesslink %}
<div class="dropdown float-end">
<a href="{% url 'wireless:wirelesslink_add' %}?interface_a={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<span class="mdi mdi-wifi-plus" aria-hidden="true"></span> Connect
</a>
</div>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if object.is_wireless %}
<div class="card">
<h5 class="card-header">Wireless</h5>
<div class="card-body">
{% with peer=object.connected_endpoint %}
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th>Local</th>
{% if peer %}
<th>Peer</th>
{% endif %}
</tr>
</thead>
<tr>
<th scope="row">Role</th>
<td>{{ object.get_rf_role_display|placeholder }}</td>
{% if peer %}
<td>{{ peer.get_rf_role_display|placeholder }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">Channel</th>
<td>{{ object.get_rf_channel_display|placeholder }}</td>
{% if peer %}
<td{% if peer.rf_channel != object.rf_channel %} class="text-danger"{% endif %}>
{{ peer.get_rf_channel_display|placeholder }}
</td>
{% endif %}
</tr>
<tr>
<th scope="row">Channel Frequency</th>
<td>
{% if object.rf_channel_frequency %}
{{ object.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{% if peer %}
<td{% if peer.rf_channel_frequency != object.rf_channel_frequency %} class="text-danger"{% endif %}>
{% if peer.rf_channel_frequency %}
{{ peer.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{% endif %}
</tr>
<tr>
<th scope="row">Channel Width</th>
<td>
{% if object.rf_channel_width %}
{{ object.rf_channel_width|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{% if peer %}
<td{% if peer.rf_channel_width != object.rf_channel_width %} class="text-danger"{% endif %}>
{% if peer.rf_channel_width %}
{{ peer.rf_channel_width|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{% endif %}
</tr>
</table>
{% endwith %}
</div>
</div>
<div class="card">
<h5 class="card-header">Wireless LANs</h5>
<div class="card-body">
<table class="table table-hover table-headings">
<thead>
<tr>
<th>Group</th>
<th>SSID</th>
</tr>
</thead>
<tbody>
{% for wlan in object.wireless_lans.all %}
<tr>
<td>
{% if wlan.group %}
<a href="{{ wlan.group.get_absolute_url }}">{{ wlan.group }}</a>
{% else %}
&mdash;
{% endif %}
</td>
<td>
<a href="{{ wlan.get_absolute_url }}">{{ wlan.ssid }}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">None</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if object.is_lag %} {% if object.is_lag %}
<div class="card"> <div class="card">
<h5 class="card-header">LAG Members</h5> <h5 class="card-header">LAG Members</h5>

View File

@ -29,6 +29,20 @@
{% render_field form.mark_connected %} {% render_field form.mark_connected %}
</div> </div>
{% if form.instance.is_wireless %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Wireless</h5>
</div>
{% render_field form.rf_role %}
{% render_field form.rf_channel %}
{% render_field form.rf_channel_frequency %}
{% render_field form.rf_channel_width %}
{% render_field form.wireless_lan_group %}
{% render_field form.wireless_lans %}
</div>
{% endif %}
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">802.1Q Switching</h5> <h5 class="offset-sm-3">802.1Q Switching</h5>

View File

@ -0,0 +1,21 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Authentication</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Type</th>
<td>{{ object.get_auth_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Cipher</th>
<td>{{ object.get_auth_cipher_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">PSK</th>
<td class="font-monospace">{{ object.auth_psk|placeholder }}</td>
</tr>
</table>
</div>
</div>

View File

@ -0,0 +1,54 @@
{% load helpers %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ interface.device.get_absolute_url }}">{{ interface.device }}</a>
</td>
</tr>
<tr>
<td>Interface</td>
<td>
<a href="{{ interface.get_absolute_url }}">{{ interface }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>
{{ interface.get_type_display }}
</td>
</tr>
<tr>
<td>Role</td>
<td>
{{ interface.get_rf_role_display|placeholder }}
</td>
</tr>
<tr>
<td>Channel</td>
<td>
{{ interface.get_rf_channel_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Channel Frequency</th>
<td>
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Channel Width</th>
<td>
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|simplify_decimal }} MHz
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
</table>

View File

@ -0,0 +1,64 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Wireless LAN</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">SSID</th>
<td>{{ object.ssid }}</td>
</tr>
<tr>
<td>Group</td>
<td>
{% if object.group %}
<a href="{{ object.group.get_absolute_url }}">{{ object.group }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">VLAN</th>
<td>
{% if object.vlan %}
<a href="{{ object.vlan.get_absolute_url }}">{{ object.vlan }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Attached Interfaces</h5>
<div class="card-body">
{% include 'inc/table.html' with table=interfaces_table %}
</div>
</div>
{% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,73 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
{% for group in object.get_ancestors %}
<li class="breadcrumb-item"><a href="{% url 'wireless:wirelesslangroup_list' %}?parent_id={{ group.pk }}">{{ group }}</a></li>
{% endfor %}
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Wireless LAN Group</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Parent</th>
<td>
{% if object.parent %}
<a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Wireless LANs</th>
<td>
<a href="{% url 'wireless:wirelesslan_list' %}?group_id={{ object.pk }}">{{ wirelesslans_table.rows|length }}</a>
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<div class="card-header">Wireless LANs</div>
<div class="card-body">
{% include 'inc/table.html' with table=wirelesslans_table %}
</div>
{% if perms.wireless.add_wirelesslan %}
<div class="card-footer text-end noprint">
<a href="{% url 'wireless:wirelesslan_add' %}?group={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Wireless LAN
</a>
</div>
{% endif %}
</div>
{% include 'inc/paginator.html' with paginator=wirelesslans_table.paginator page=wirelesslans_table.page %}
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,55 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Interface A</h5>
<div class="card-body">
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %}
</div>
</div>
<div class="card">
<h5 class="card-header">Link Properties</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Status</th>
<td>
<span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
</td>
</tr>
<tr>
<th scope="row">SSID</th>
<td>{{ object.ssid|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Interface B</h5>
<div class="card-body">
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
</div>
</div>
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="row">
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side A</h5>
</div>
{% render_field form.device_a %}
{% render_field form.interface_a %}
</div>
</div>
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side B</h5>
</div>
{% render_field form.device_b %}
{% render_field form.interface_b %}
</div>
</div>
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,4 +1,5 @@
import datetime import datetime
import decimal
import json import json
import re import re
from typing import Dict, Any from typing import Dict, Any
@ -146,6 +147,19 @@ def humanize_megabytes(mb):
return f'{mb} MB' return f'{mb} MB'
@register.filter()
def simplify_decimal(value):
"""
Return the simplest expression of a decimal value. Examples:
1.00 => '1'
1.20 => '1.2'
1.23 => '1.23'
"""
if type(value) is not decimal.Decimal:
return value
return str(value).rstrip('0').rstrip('.')
@register.filter() @register.filter()
def tzoffset(value): def tzoffset(value):
""" """

View File

View File

View File

@ -0,0 +1,36 @@
from rest_framework import serializers
from netbox.api import WritableNestedSerializer
from wireless.models import *
__all__ = (
'NestedWirelessLANSerializer',
'NestedWirelessLANGroupSerializer',
'NestedWirelessLinkSerializer',
)
class NestedWirelessLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
wirelesslan_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = WirelessLANGroup
fields = ['id', 'url', 'display', 'name', 'slug', 'wirelesslan_count', '_depth']
class NestedWirelessLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
class Meta:
model = WirelessLAN
fields = ['id', 'url', 'display', 'ssid']
class NestedWirelessLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail')
class Meta:
model = WirelessLink
fields = ['id', 'url', 'display', 'ssid']

View File

@ -0,0 +1,59 @@
from rest_framework import serializers
from dcim.choices import LinkStatusChoices
from dcim.api.serializers import NestedInterfaceSerializer
from ipam.api.serializers import NestedVLANSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
from wireless.choices import *
from wireless.models import *
from .nested_serializers import *
__all__ = (
'WirelessLANGroupSerializer',
'WirelessLANSerializer',
'WirelessLinkSerializer',
)
class WirelessLANGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None)
wirelesslan_count = serializers.IntegerField(read_only=True)
class Meta:
model = WirelessLANGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'wirelesslan_count', '_depth',
]
class WirelessLANSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
group = NestedWirelessLANGroupSerializer(required=False, allow_null=True)
vlan = NestedVLANSerializer(required=False, allow_null=True)
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
class Meta:
model = WirelessLAN
fields = [
'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk',
]
class WirelessLinkSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail')
status = ChoiceField(choices=LinkStatusChoices, required=False)
interface_a = NestedInterfaceSerializer()
interface_b = NestedInterfaceSerializer()
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
class Meta:
model = WirelessLink
fields = [
'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type',
'auth_cipher', 'auth_psk',
]

View File

@ -0,0 +1,13 @@
from netbox.api import OrderedDefaultRouter
from . import views
router = OrderedDefaultRouter()
router.APIRootView = views.WirelessRootView
router.register('wireless-lan-groupss', views.WirelessLANGroupViewSet)
router.register('wireless-lans', views.WirelessLANViewSet)
router.register('wireless-links', views.WirelessLinkViewSet)
app_name = 'wireless-api'
urlpatterns = router.urls

View File

@ -0,0 +1,38 @@
from rest_framework.routers import APIRootView
from extras.api.views import CustomFieldModelViewSet
from wireless import filtersets
from wireless.models import *
from . import serializers
class WirelessRootView(APIRootView):
"""
Wireless API root view
"""
def get_view_name(self):
return 'Wireless'
class WirelessLANGroupViewSet(CustomFieldModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
'group',
'wirelesslan_count',
cumulative=True
)
serializer_class = serializers.WirelessLANGroupSerializer
filterset_class = filtersets.WirelessLANGroupFilterSet
class WirelessLANViewSet(CustomFieldModelViewSet):
queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags')
serializer_class = serializers.WirelessLANSerializer
filterset_class = filtersets.WirelessLANFilterSet
class WirelessLinkViewSet(CustomFieldModelViewSet):
queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags')
serializer_class = serializers.WirelessLinkSerializer
filterset_class = filtersets.WirelessLinkFilterSet

8
netbox/wireless/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class WirelessConfig(AppConfig):
name = 'wireless'
def ready(self):
import wireless.signals

191
netbox/wireless/choices.py Normal file
View File

@ -0,0 +1,191 @@
from utilities.choices import ChoiceSet
class WirelessRoleChoices(ChoiceSet):
ROLE_AP = 'ap'
ROLE_STATION = 'station'
CHOICES = (
(ROLE_AP, 'Access point'),
(ROLE_STATION, 'Station'),
)
class WirelessChannelChoices(ChoiceSet):
# 2.4 GHz
CHANNEL_24G_1 = '2.4g-1-2412-22'
CHANNEL_24G_2 = '2.4g-2-2417-22'
CHANNEL_24G_3 = '2.4g-3-2422-22'
CHANNEL_24G_4 = '2.4g-4-2427-22'
CHANNEL_24G_5 = '2.4g-5-2432-22'
CHANNEL_24G_6 = '2.4g-6-2437-22'
CHANNEL_24G_7 = '2.4g-7-2442-22'
CHANNEL_24G_8 = '2.4g-8-2447-22'
CHANNEL_24G_9 = '2.4g-9-2452-22'
CHANNEL_24G_10 = '2.4g-10-2457-22'
CHANNEL_24G_11 = '2.4g-11-2462-22'
CHANNEL_24G_12 = '2.4g-12-2467-22'
CHANNEL_24G_13 = '2.4g-13-2472-22'
# 5 GHz
CHANNEL_5G_32 = '5g-32-5160-20'
CHANNEL_5G_34 = '5g-34-5170-40'
CHANNEL_5G_36 = '5g-36-5180-20'
CHANNEL_5G_38 = '5g-38-5190-40'
CHANNEL_5G_40 = '5g-40-5200-20'
CHANNEL_5G_42 = '5g-42-5210-80'
CHANNEL_5G_44 = '5g-44-5220-20'
CHANNEL_5G_46 = '5g-46-5230-40'
CHANNEL_5G_48 = '5g-48-5240-20'
CHANNEL_5G_50 = '5g-50-5250-160'
CHANNEL_5G_52 = '5g-52-5260-20'
CHANNEL_5G_54 = '5g-54-5270-40'
CHANNEL_5G_56 = '5g-56-5280-20'
CHANNEL_5G_58 = '5g-58-5290-80'
CHANNEL_5G_60 = '5g-60-5300-20'
CHANNEL_5G_62 = '5g-62-5310-40'
CHANNEL_5G_64 = '5g-64-5320-20'
CHANNEL_5G_100 = '5g-100-5500-20'
CHANNEL_5G_102 = '5g-102-5510-40'
CHANNEL_5G_104 = '5g-104-5520-20'
CHANNEL_5G_106 = '5g-106-5530-80'
CHANNEL_5G_108 = '5g-108-5540-20'
CHANNEL_5G_110 = '5g-110-5550-40'
CHANNEL_5G_112 = '5g-112-5560-20'
CHANNEL_5G_114 = '5g-114-5570-160'
CHANNEL_5G_116 = '5g-116-5580-20'
CHANNEL_5G_118 = '5g-118-5590-40'
CHANNEL_5G_120 = '5g-120-5600-20'
CHANNEL_5G_122 = '5g-122-5610-80'
CHANNEL_5G_124 = '5g-124-5620-20'
CHANNEL_5G_126 = '5g-126-5630-40'
CHANNEL_5G_128 = '5g-128-5640-20'
CHANNEL_5G_132 = '5g-132-5660-20'
CHANNEL_5G_134 = '5g-134-5670-40'
CHANNEL_5G_136 = '5g-136-5680-20'
CHANNEL_5G_138 = '5g-138-5690-80'
CHANNEL_5G_140 = '5g-140-5700-20'
CHANNEL_5G_142 = '5g-142-5710-40'
CHANNEL_5G_144 = '5g-144-5720-20'
CHANNEL_5G_149 = '5g-149-5745-20'
CHANNEL_5G_151 = '5g-151-5755-40'
CHANNEL_5G_153 = '5g-153-5765-20'
CHANNEL_5G_155 = '5g-155-5775-80'
CHANNEL_5G_157 = '5g-157-5785-20'
CHANNEL_5G_159 = '5g-159-5795-40'
CHANNEL_5G_161 = '5g-161-5805-20'
CHANNEL_5G_163 = '5g-163-5815-160'
CHANNEL_5G_165 = '5g-165-5825-20'
CHANNEL_5G_167 = '5g-167-5835-40'
CHANNEL_5G_169 = '5g-169-5845-20'
CHANNEL_5G_171 = '5g-171-5855-80'
CHANNEL_5G_173 = '5g-173-5865-20'
CHANNEL_5G_175 = '5g-175-5875-40'
CHANNEL_5G_177 = '5g-177-5885-20'
CHOICES = (
(
'2.4 GHz (802.11b/g/n/ax)',
(
(CHANNEL_24G_1, '1 (2412 MHz)'),
(CHANNEL_24G_2, '2 (2417 MHz)'),
(CHANNEL_24G_3, '3 (2422 MHz)'),
(CHANNEL_24G_4, '4 (2427 MHz)'),
(CHANNEL_24G_5, '5 (2432 MHz)'),
(CHANNEL_24G_6, '6 (2437 MHz)'),
(CHANNEL_24G_7, '7 (2442 MHz)'),
(CHANNEL_24G_8, '8 (2447 MHz)'),
(CHANNEL_24G_9, '9 (2452 MHz)'),
(CHANNEL_24G_10, '10 (2457 MHz)'),
(CHANNEL_24G_11, '11 (2462 MHz)'),
(CHANNEL_24G_12, '12 (2467 MHz)'),
(CHANNEL_24G_13, '13 (2472 MHz)'),
)
),
(
'5 GHz (802.11a/n/ac/ax)',
(
(CHANNEL_5G_32, '32 (5160/20 MHz)'),
(CHANNEL_5G_34, '34 (5170/40 MHz)'),
(CHANNEL_5G_36, '36 (5180/20 MHz)'),
(CHANNEL_5G_38, '38 (5190/40 MHz)'),
(CHANNEL_5G_40, '40 (5200/20 MHz)'),
(CHANNEL_5G_42, '42 (5210/80 MHz)'),
(CHANNEL_5G_44, '44 (5220/20 MHz)'),
(CHANNEL_5G_46, '46 (5230/40 MHz)'),
(CHANNEL_5G_48, '48 (5240/20 MHz)'),
(CHANNEL_5G_50, '50 (5250/160 MHz)'),
(CHANNEL_5G_52, '52 (5260/20 MHz)'),
(CHANNEL_5G_54, '54 (5270/40 MHz)'),
(CHANNEL_5G_56, '56 (5280/20 MHz)'),
(CHANNEL_5G_58, '58 (5290/80 MHz)'),
(CHANNEL_5G_60, '60 (5300/20 MHz)'),
(CHANNEL_5G_62, '62 (5310/40 MHz)'),
(CHANNEL_5G_64, '64 (5320/20 MHz)'),
(CHANNEL_5G_100, '100 (5500/20 MHz)'),
(CHANNEL_5G_102, '102 (5510/40 MHz)'),
(CHANNEL_5G_104, '104 (5520/20 MHz)'),
(CHANNEL_5G_106, '106 (5530/80 MHz)'),
(CHANNEL_5G_108, '108 (5540/20 MHz)'),
(CHANNEL_5G_110, '110 (5550/40 MHz)'),
(CHANNEL_5G_112, '112 (5560/20 MHz)'),
(CHANNEL_5G_114, '114 (5570/160 MHz)'),
(CHANNEL_5G_116, '116 (5580/20 MHz)'),
(CHANNEL_5G_118, '118 (5590/40 MHz)'),
(CHANNEL_5G_120, '120 (5600/20 MHz)'),
(CHANNEL_5G_122, '122 (5610/80 MHz)'),
(CHANNEL_5G_124, '124 (5620/20 MHz)'),
(CHANNEL_5G_126, '126 (5630/40 MHz)'),
(CHANNEL_5G_128, '128 (5640/20 MHz)'),
(CHANNEL_5G_132, '132 (5660/20 MHz)'),
(CHANNEL_5G_134, '134 (5670/40 MHz)'),
(CHANNEL_5G_136, '136 (5680/20 MHz)'),
(CHANNEL_5G_138, '138 (5690/80 MHz)'),
(CHANNEL_5G_140, '140 (5700/20 MHz)'),
(CHANNEL_5G_142, '142 (5710/40 MHz)'),
(CHANNEL_5G_144, '144 (5720/20 MHz)'),
(CHANNEL_5G_149, '149 (5745/20 MHz)'),
(CHANNEL_5G_151, '151 (5755/40 MHz)'),
(CHANNEL_5G_153, '153 (5765/20 MHz)'),
(CHANNEL_5G_155, '155 (5775/80 MHz)'),
(CHANNEL_5G_157, '157 (5785/20 MHz)'),
(CHANNEL_5G_159, '159 (5795/40 MHz)'),
(CHANNEL_5G_161, '161 (5805/20 MHz)'),
(CHANNEL_5G_163, '163 (5815/160 MHz)'),
(CHANNEL_5G_165, '165 (5825/20 MHz)'),
(CHANNEL_5G_167, '167 (5835/40 MHz)'),
(CHANNEL_5G_169, '169 (5845/20 MHz)'),
(CHANNEL_5G_171, '171 (5855/80 MHz)'),
(CHANNEL_5G_173, '173 (5865/20 MHz)'),
(CHANNEL_5G_175, '175 (5875/40 MHz)'),
(CHANNEL_5G_177, '177 (5885/20 MHz)'),
)
),
)
class WirelessAuthTypeChoices(ChoiceSet):
TYPE_OPEN = 'open'
TYPE_WEP = 'wep'
TYPE_WPA_PERSONAL = 'wpa-personal'
TYPE_WPA_ENTERPRISE = 'wpa-enterprise'
CHOICES = (
(TYPE_OPEN, 'Open'),
(TYPE_WEP, 'WEP'),
(TYPE_WPA_PERSONAL, 'WPA Personal (PSK)'),
(TYPE_WPA_ENTERPRISE, 'WPA Enterprise'),
)
class WirelessAuthCipherChoices(ChoiceSet):
CIPHER_AUTO = 'auto'
CIPHER_TKIP = 'tkip'
CIPHER_AES = 'aes'
CHOICES = (
(CIPHER_AUTO, 'Auto'),
(CIPHER_TKIP, 'TKIP'),
(CIPHER_AES, 'AES'),
)

View File

@ -0,0 +1,2 @@
SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007
PSK_MAX_LENGTH = 64

View File

@ -0,0 +1,102 @@
import django_filters
from django.db.models import Q
from dcim.choices import LinkStatusChoices
from extras.filters import TagFilter
from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
from .models import *
__all__ = (
'WirelessLANFilterSet',
'WirelessLANGroupFilterSet',
'WirelessLinkFilterSet',
)
class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all()
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=WirelessLANGroup.objects.all(),
to_field_name='slug'
)
class Meta:
model = WirelessLANGroup
fields = ['id', 'name', 'slug', 'description']
class WirelessLANFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all(),
field_name='group',
lookup_expr='in'
)
group = TreeNodeMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all(),
field_name='group',
lookup_expr='in',
to_field_name='slug'
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all()
)
auth_type = django_filters.MultipleChoiceFilter(
choices=WirelessAuthTypeChoices
)
auth_cipher = django_filters.MultipleChoiceFilter(
choices=WirelessAuthCipherChoices
)
tag = TagFilter()
class Meta:
model = WirelessLAN
fields = ['id', 'ssid', 'auth_psk']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(ssid__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
class WirelessLinkFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
status = django_filters.MultipleChoiceFilter(
choices=LinkStatusChoices
)
auth_type = django_filters.MultipleChoiceFilter(
choices=WirelessAuthTypeChoices
)
auth_cipher = django_filters.MultipleChoiceFilter(
choices=WirelessAuthCipherChoices
)
tag = TagFilter()
class Meta:
model = WirelessLink
fields = ['id', 'ssid', 'auth_psk']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(ssid__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)

View File

@ -0,0 +1,4 @@
from .models import *
from .filtersets import *
from .bulk_edit import *
from .bulk_import import *

View File

@ -0,0 +1,101 @@
from django import forms
from dcim.choices import LinkStatusChoices
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.models import VLAN
from utilities.forms import BootstrapMixin, DynamicModelChoiceField
from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH
from wireless.models import *
__all__ = (
'WirelessLANBulkEditForm',
'WirelessLANGroupBulkEditForm',
'WirelessLinkBulkEditForm',
)
class WirelessLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
parent = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['parent', 'description']
class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=WirelessLAN.objects.all(),
widget=forms.MultipleHiddenInput
)
group = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False
)
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
required=False
)
auth_psk = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk']
class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=WirelessLink.objects.all(),
widget=forms.MultipleHiddenInput
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
)
status = forms.ChoiceField(
choices=LinkStatusChoices,
required=False
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
required=False
)
auth_psk = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk']

View File

@ -0,0 +1,83 @@
from dcim.choices import LinkStatusChoices
from dcim.models import Interface
from extras.forms import CustomFieldModelCSVForm
from ipam.models import VLAN
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
from wireless.choices import *
from wireless.models import *
__all__ = (
'WirelessLANCSVForm',
'WirelessLANGroupCSVForm',
'WirelessLinkCSVForm',
)
class WirelessLANGroupCSVForm(CustomFieldModelCSVForm):
parent = CSVModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Parent group'
)
slug = SlugField()
class Meta:
model = WirelessLANGroup
fields = ('name', 'slug', 'parent', 'description')
class WirelessLANCSVForm(CustomFieldModelCSVForm):
group = CSVModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned group'
)
vlan = CSVModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
to_field_name='name',
help_text='Bridged VLAN'
)
auth_type = CSVChoiceField(
choices=WirelessAuthTypeChoices,
required=False,
help_text='Authentication type'
)
auth_cipher = CSVChoiceField(
choices=WirelessAuthCipherChoices,
required=False,
help_text='Authentication cipher'
)
class Meta:
model = WirelessLAN
fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk')
class WirelessLinkCSVForm(CustomFieldModelCSVForm):
status = CSVChoiceField(
choices=LinkStatusChoices,
help_text='Connection status'
)
interface_a = CSVModelChoiceField(
queryset=Interface.objects.all()
)
interface_b = CSVModelChoiceField(
queryset=Interface.objects.all()
)
auth_type = CSVChoiceField(
choices=WirelessAuthTypeChoices,
required=False,
help_text='Authentication type'
)
auth_cipher = CSVChoiceField(
choices=WirelessAuthCipherChoices,
required=False,
help_text='Authentication cipher'
)
class Meta:
model = WirelessLink
fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk')

View File

@ -0,0 +1,104 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.choices import LinkStatusChoices
from extras.forms import CustomFieldModelFilterForm
from utilities.forms import (
add_blank_choice, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField,
)
from wireless.choices import *
from wireless.models import *
__all__ = (
'WirelessLANFilterForm',
'WirelessLANGroupFilterForm',
'WirelessLinkFilterForm',
)
class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = WirelessLANGroup
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
parent_id = DynamicModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
label=_('Parent group'),
fetch_trigger='open'
)
class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = WirelessLAN
field_groups = [
('q', 'tag'),
('group_id',),
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
ssid = forms.CharField(
required=False,
label='SSID'
)
group_id = DynamicModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
null_option='None',
label=_('Group'),
fetch_trigger='open'
)
auth_type = forms.ChoiceField(
required=False,
choices=add_blank_choice(WirelessAuthTypeChoices),
widget=StaticSelect()
)
auth_cipher = forms.ChoiceField(
required=False,
choices=add_blank_choice(WirelessAuthCipherChoices),
widget=StaticSelect()
)
auth_psk = forms.CharField(
required=False
)
tag = TagFilterField(model)
class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = WirelessLink
field_groups = [
['q', 'tag'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
ssid = forms.CharField(
required=False,
label='SSID'
)
status = forms.ChoiceField(
required=False,
choices=add_blank_choice(LinkStatusChoices),
widget=StaticSelect()
)
auth_type = forms.ChoiceField(
required=False,
choices=add_blank_choice(WirelessAuthTypeChoices),
widget=StaticSelect()
)
auth_cipher = forms.ChoiceField(
required=False,
choices=add_blank_choice(WirelessAuthCipherChoices),
widget=StaticSelect()
)
auth_psk = forms.CharField(
required=False
)
tag = TagFilterField(model)

View File

@ -0,0 +1,166 @@
from dcim.models import Device, Interface, Location, Site
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from ipam.models import VLAN
from utilities.forms import (
BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect,
)
from wireless.models import *
__all__ = (
'WirelessLANForm',
'WirelessLANGroupForm',
'WirelessLinkForm',
)
class WirelessLANGroupForm(BootstrapMixin, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False
)
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = WirelessLANGroup
fields = [
'parent', 'name', 'slug', 'description', 'tags',
]
class WirelessLANForm(BootstrapMixin, CustomFieldModelForm):
group = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False
)
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='VLAN'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = WirelessLAN
fields = [
'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
]
fieldsets = (
('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
('VLAN', ('vlan',)),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
)
widgets = {
'auth_type': StaticSelect,
'auth_cipher': StaticSelect,
}
class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm):
site_a = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
initial_params={
'devices': '$device_a',
}
)
location_a = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
label='Location',
initial_params={
'devices': '$device_a',
}
)
device_a = DynamicModelChoiceField(
queryset=Device.objects.all(),
query_params={
'site_id': '$site_a',
'location_id': '$location_a',
},
required=False,
label='Device',
initial_params={
'interfaces': '$interface_a'
}
)
interface_a = DynamicModelChoiceField(
queryset=Interface.objects.all(),
query_params={
'kind': 'wireless',
'device_id': '$device_a',
},
disabled_indicator='_occupied',
label='Interface'
)
site_b = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
initial_params={
'devices': '$device_b',
}
)
location_b = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
label='Location',
initial_params={
'devices': '$device_b',
}
)
device_b = DynamicModelChoiceField(
queryset=Device.objects.all(),
query_params={
'site_id': '$site_b',
'location_id': '$location_b',
},
required=False,
label='Device',
initial_params={
'interfaces': '$interface_b'
}
)
interface_b = DynamicModelChoiceField(
queryset=Interface.objects.all(),
query_params={
'kind': 'wireless',
'device_id': '$device_b',
},
disabled_indicator='_occupied',
label='Interface'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = WirelessLink
fields = [
'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
]
fieldsets = (
('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')),
('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')),
('Link', ('status', 'ssid', 'description', 'tags')),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
)
widgets = {
'status': StaticSelect,
'auth_type': StaticSelect,
'auth_cipher': StaticSelect,
}
labels = {
'auth_type': 'Type',
'auth_cipher': 'Cipher',
}

View File

View File

@ -0,0 +1,15 @@
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class WirelessQuery(graphene.ObjectType):
wireless_lan = ObjectField(WirelessLANType)
wireless_lan_list = ObjectListField(WirelessLANType)
wireless_lan_group = ObjectField(WirelessLANGroupType)
wireless_lan_group_list = ObjectListField(WirelessLANGroupType)
wireless_link = ObjectField(WirelessLinkType)
wireless_link_list = ObjectListField(WirelessLinkType)

View File

@ -0,0 +1,44 @@
from wireless import filtersets, models
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
__all__ = (
'WirelessLANType',
'WirelessLANGroupType',
'WirelessLinkType',
)
class WirelessLANGroupType(OrganizationalObjectType):
class Meta:
model = models.WirelessLANGroup
fields = '__all__'
filterset_class = filtersets.WirelessLANGroupFilterSet
class WirelessLANType(PrimaryObjectType):
class Meta:
model = models.WirelessLAN
fields = '__all__'
filterset_class = filtersets.WirelessLANFilterSet
def resolve_auth_type(self, info):
return self.auth_type or None
def resolve_auth_cipher(self, info):
return self.auth_cipher or None
class WirelessLinkType(PrimaryObjectType):
class Meta:
model = models.WirelessLink
fields = '__all__'
filterset_class = filtersets.WirelessLinkFilterSet
def resolve_auth_type(self, info):
return self.auth_type or None
def resolve_auth_cipher(self, info):
return self.auth_cipher or None

View File

@ -0,0 +1,80 @@
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
import taggit.managers
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '0139_rename_cable_peer'),
('extras', '0062_clear_secrets_changelog'),
('ipam', '0050_iprange'),
]
operations = [
migrations.CreateModel(
name='WirelessLANGroup',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wireless.wirelesslangroup')),
],
options={
'ordering': ('name', 'pk'),
'unique_together': {('parent', 'name')},
},
),
migrations.CreateModel(
name='WirelessLAN',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('ssid', models.CharField(max_length=32)),
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
('description', models.CharField(blank=True, max_length=200)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')),
],
options={
'verbose_name': 'Wireless LAN',
'ordering': ('ssid', 'pk'),
},
),
migrations.CreateModel(
name='WirelessLink',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('ssid', models.CharField(blank=True, max_length=32)),
('status', models.CharField(default='connected', max_length=50)),
('description', models.CharField(blank=True, max_length=200)),
('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
('interface_a', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')),
('interface_b', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ['pk'],
'unique_together': {('interface_a', 'interface_b')},
},
),
]

View File

@ -0,0 +1,41 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wireless', '0001_wireless'),
]
operations = [
migrations.AddField(
model_name='wirelesslan',
name='auth_cipher',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='wirelesslan',
name='auth_psk',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='wirelesslan',
name='auth_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='wirelesslink',
name='auth_cipher',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='wirelesslink',
name='auth_psk',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='wirelesslink',
name='auth_type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

209
netbox/wireless/models.py Normal file
View File

@ -0,0 +1,209 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
from extras.utils import extras_features
from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
from .choices import *
from .constants import *
__all__ = (
'WirelessLAN',
'WirelessLANGroup',
'WirelessLink',
)
class WirelessAuthenticationBase(models.Model):
"""
Abstract model for attaching attributes related to wireless authentication.
"""
auth_type = models.CharField(
max_length=50,
choices=WirelessAuthTypeChoices,
blank=True
)
auth_cipher = models.CharField(
max_length=50,
choices=WirelessAuthCipherChoices,
blank=True
)
auth_psk = models.CharField(
max_length=PSK_MAX_LENGTH,
blank=True,
verbose_name='Pre-shared key'
)
class Meta:
abstract = True
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class WirelessLANGroup(NestedGroupModel):
"""
A nested grouping of WirelessLANs
"""
name = models.CharField(
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
description = models.CharField(
max_length=200,
blank=True
)
class Meta:
ordering = ('name', 'pk')
unique_together = (
('parent', 'name')
)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('wireless:wirelesslangroup', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
"""
A wireless network formed among an arbitrary number of access point and clients.
"""
ssid = models.CharField(
max_length=SSID_MAX_LENGTH,
verbose_name='SSID'
)
group = models.ForeignKey(
to='wireless.WirelessLANGroup',
on_delete=models.SET_NULL,
related_name='wireless_lans',
blank=True,
null=True
)
vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.PROTECT,
blank=True,
null=True,
verbose_name='VLAN'
)
description = models.CharField(
max_length=200,
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('ssid', 'pk')
verbose_name = 'Wireless LAN'
def __str__(self):
return self.ssid
def get_absolute_url(self):
return reverse('wireless:wirelesslan', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
"""
A point-to-point connection between two wireless Interfaces.
"""
interface_a = models.ForeignKey(
to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
interface_b = models.ForeignKey(
to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
ssid = models.CharField(
max_length=SSID_MAX_LENGTH,
blank=True,
verbose_name='SSID'
)
status = models.CharField(
max_length=50,
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
)
description = models.CharField(
max_length=200,
blank=True
)
# Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their
# associated Devices.
_interface_a_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_interface_b_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = ('ssid', 'status')
class Meta:
ordering = ['pk']
unique_together = ('interface_a', 'interface_b')
def __str__(self):
return f'#{self.pk}'
def get_absolute_url(self):
return reverse('wireless:wirelesslink', args=[self.pk])
def get_status_class(self):
return LinkStatusChoices.CSS_CLASSES.get(self.status)
def clean(self):
# Validate interface types
if self.interface_a.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': f"{self.interface_a.get_type_display()} is not a wireless interface."
})
if self.interface_b.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': f"{self.interface_b.get_type_display()} is not a wireless interface."
})
def save(self, *args, **kwargs):
# Store the parent Device for the A and B interfaces
self._interface_a_device = self.interface_a.device
self._interface_b_device = self.interface_b.device
super().save(*args, **kwargs)

View File

@ -0,0 +1,66 @@
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from dcim.models import CablePath, Interface
from dcim.utils import create_cablepath
from .models import WirelessLink
#
# Wireless links
#
@receiver(post_save, sender=WirelessLink)
def update_connected_interfaces(instance, created, raw=False, **kwargs):
"""
When a WirelessLink is saved, save a reference to it on each connected interface.
"""
logger = logging.getLogger('netbox.wireless.wirelesslink')
if raw:
logger.debug(f"Skipping endpoint updates for imported wireless link {instance}")
return
if instance.interface_a.wireless_link != instance:
logger.debug(f"Updating interface A for wireless link {instance}")
instance.interface_a.wireless_link = instance
instance.interface_a._link_peer = instance.interface_b
instance.interface_a.save()
if instance.interface_b.cable != instance:
logger.debug(f"Updating interface B for wireless link {instance}")
instance.interface_b.wireless_link = instance
instance.interface_b._link_peer = instance.interface_a
instance.interface_b.save()
# Create/update cable paths
if created:
for interface in (instance.interface_a, instance.interface_b):
create_cablepath(interface)
@receiver(post_delete, sender=WirelessLink)
def nullify_connected_interfaces(instance, **kwargs):
"""
When a WirelessLink is deleted, update its two connected Interfaces
"""
logger = logging.getLogger('netbox.wireless.wirelesslink')
if instance.interface_a is not None:
logger.debug(f"Nullifying interface A for wireless link {instance}")
Interface.objects.filter(pk=instance.interface_a.pk).update(
wireless_link=None,
_link_peer_type=None,
_link_peer_id=None
)
if instance.interface_b is not None:
logger.debug(f"Nullifying interface B for wireless link {instance}")
Interface.objects.filter(pk=instance.interface_b.pk).update(
wireless_link=None,
_link_peer_type=None,
_link_peer_id=None
)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
cablepath.delete()

110
netbox/wireless/tables.py Normal file
View File

@ -0,0 +1,110 @@
import django_tables2 as tables
from dcim.models import Interface
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .models import *
__all__ = (
'WirelessLANTable',
'WirelessLANGroupTable',
'WirelessLinkTable',
)
class WirelessLANGroupTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
linkify=True
)
wirelesslan_count = LinkedCountColumn(
viewname='wireless:wirelesslan_list',
url_params={'group_id': 'pk'},
verbose_name='Wireless LANs'
)
tags = TagColumn(
url_name='wireless:wirelesslangroup_list'
)
actions = ButtonsColumn(WirelessLANGroup)
class Meta(BaseTable.Meta):
model = WirelessLANGroup
fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions')
class WirelessLANTable(BaseTable):
pk = ToggleColumn()
ssid = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
interface_count = tables.Column(
verbose_name='Interfaces'
)
tags = TagColumn(
url_name='wireless:wirelesslan_list'
)
class Meta(BaseTable.Meta):
model = WirelessLAN
fields = (
'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk',
'tags',
)
default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count')
class WirelessLANInterfacesTable(BaseTable):
pk = ToggleColumn()
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
model = Interface
fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel')
default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel')
class WirelessLinkTable(BaseTable):
pk = ToggleColumn()
id = tables.Column(
linkify=True,
verbose_name='ID'
)
status = ChoiceFieldColumn()
device_a = tables.Column(
accessor=tables.A('interface_a__device'),
linkify=True
)
interface_a = tables.Column(
linkify=True
)
device_b = tables.Column(
accessor=tables.A('interface_b__device'),
linkify=True
)
interface_b = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='wireless:wirelesslink_list'
)
class Meta(BaseTable.Meta):
model = WirelessLink
fields = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description',
'auth_type', 'auth_cipher', 'auth_psk', 'tags',
)
default_columns = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type',
'description',
)

View File

View File

@ -0,0 +1,141 @@
from django.urls import reverse
from wireless.choices import *
from wireless.models import *
from dcim.choices import InterfaceTypeChoices
from dcim.models import Interface
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
class AppTest(APITestCase):
def test_root(self):
url = reverse('wireless-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)
class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase):
model = WirelessLANGroup
brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'url', 'wirelesslan_count']
create_data = [
{
'name': 'Wireless LAN Group 4',
'slug': 'wireless-lan-group-4',
},
{
'name': 'Wireless LAN Group 5',
'slug': 'wireless-lan-group-5',
},
{
'name': 'Wireless LAN Group 6',
'slug': 'wireless-lan-group-6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
WirelessLANGroup.objects.create(name='Wireless LAN Group 1', slug='wireless-lan-group-1')
WirelessLANGroup.objects.create(name='Wireless LAN Group 2', slug='wireless-lan-group-2')
WirelessLANGroup.objects.create(name='Wireless LAN Group 3', slug='wireless-lan-group-3')
class WirelessLANTest(APIViewTestCases.APIViewTestCase):
model = WirelessLAN
brief_fields = ['display', 'id', 'ssid', 'url']
@classmethod
def setUpTestData(cls):
groups = (
WirelessLANGroup(name='Group 1', slug='group-1'),
WirelessLANGroup(name='Group 2', slug='group-2'),
WirelessLANGroup(name='Group 3', slug='group-3'),
)
for group in groups:
group.save()
wireless_lans = (
WirelessLAN(ssid='WLAN1'),
WirelessLAN(ssid='WLAN2'),
WirelessLAN(ssid='WLAN3'),
)
WirelessLAN.objects.bulk_create(wireless_lans)
cls.create_data = [
{
'ssid': 'WLAN4',
'group': groups[0].pk,
'auth_type': WirelessAuthTypeChoices.TYPE_OPEN,
},
{
'ssid': 'WLAN5',
'group': groups[1].pk,
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
},
{
'ssid': 'WLAN6',
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE,
},
]
cls.bulk_update_data = {
'group': groups[2].pk,
'description': 'New description',
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES,
'auth_psk': 'abc123def456',
}
class WirelessLinkTest(APIViewTestCases.APIViewTestCase):
model = WirelessLink
brief_fields = ['display', 'id', 'ssid', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod
def setUpTestData(cls):
device = create_test_device('test-device')
interfaces = [
Interface(
device=device,
name=f'radio{i}',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
) for i in range(12)
]
Interface.objects.bulk_create(interfaces)
wireless_links = (
WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]),
WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]),
WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]),
)
WirelessLink.objects.bulk_create(wireless_links)
cls.create_data = [
{
'interface_a': interfaces[6].pk,
'interface_b': interfaces[7].pk,
'ssid': 'LINK4',
},
{
'interface_a': interfaces[8].pk,
'interface_b': interfaces[9].pk,
'ssid': 'LINK5',
},
{
'interface_a': interfaces[10].pk,
'interface_b': interfaces[11].pk,
'ssid': 'LINK6',
},
]

View File

@ -0,0 +1,194 @@
from django.test import TestCase
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
from dcim.models import Interface
from ipam.models import VLAN
from wireless.choices import *
from wireless.filtersets import *
from wireless.models import *
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLANGroup.objects.all()
filterset = WirelessLANGroupFilterSet
@classmethod
def setUpTestData(cls):
groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'),
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'),
)
for group in groups:
group.save()
child_groups = (
WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]),
WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]),
WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]),
WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]),
WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]),
WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=groups[2]),
)
for group in child_groups:
group.save()
def test_name(self):
params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']}
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)
def test_parent(self):
parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2]
params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLAN.objects.all()
filterset = WirelessLANFilterSet
@classmethod
def setUpTestData(cls):
groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'),
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'),
)
for group in groups:
group.save()
vlans = (
VLAN(name='VLAN1', vid=1),
VLAN(name='VLAN2', vid=2),
VLAN(name='VLAN3', vid=3),
)
VLAN.objects.bulk_create(vlans)
wireless_lans = (
WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'),
WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'),
WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'),
)
WirelessLAN.objects.bulk_create(wireless_lans)
def test_ssid(self):
params = {'ssid': ['WLAN1', 'WLAN2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_group(self):
groups = WirelessLANGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vlan(self):
vlans = VLAN.objects.all()[:2]
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_type(self):
params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_cipher(self):
params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_psk(self):
params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLink.objects.all()
filterset = WirelessLinkFilterSet
@classmethod
def setUpTestData(cls):
devices = (
create_test_device('device1'),
create_test_device('device2'),
create_test_device('device3'),
create_test_device('device4'),
)
interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC),
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC),
)
Interface.objects.bulk_create(interfaces)
# Wireless links
WirelessLink(
interface_a=interfaces[0],
interface_b=interfaces[2],
ssid='LINK1',
status=LinkStatusChoices.STATUS_CONNECTED,
auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1'
).save()
WirelessLink(
interface_a=interfaces[1],
interface_b=interfaces[3],
ssid='LINK2',
status=LinkStatusChoices.STATUS_PLANNED,
auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2'
).save()
WirelessLink(
interface_a=interfaces[4],
interface_b=interfaces[6],
ssid='LINK3',
status=LinkStatusChoices.STATUS_DECOMMISSIONING,
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3'
).save()
WirelessLink(
interface_a=interfaces[5],
interface_b=interfaces[7],
ssid='LINK4'
).save()
def test_ssid(self):
params = {'ssid': ['LINK1', 'LINK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [LinkStatusChoices.STATUS_PLANNED, LinkStatusChoices.STATUS_DECOMMISSIONING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_type(self):
params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_cipher(self):
params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_auth_psk(self):
params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -0,0 +1,123 @@
from wireless.choices import *
from wireless.models import *
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
from dcim.models import Interface
from utilities.testing import ViewTestCases, create_tags, create_test_device
class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = WirelessLANGroup
@classmethod
def setUpTestData(cls):
groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'),
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'),
)
for group in groups:
group.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Wireless LAN Group X',
'slug': 'wireless-lan-group-x',
'parent': groups[2].pk,
'description': 'A new wireless LAN group',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug,description",
"Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group",
"Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group",
"Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = WirelessLAN
@classmethod
def setUpTestData(cls):
groups = (
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'),
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'),
)
for group in groups:
group.save()
WirelessLAN.objects.bulk_create([
WirelessLAN(group=groups[0], ssid='WLAN1'),
WirelessLAN(group=groups[0], ssid='WLAN2'),
WirelessLAN(group=groups[0], ssid='WLAN3'),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'ssid': 'WLAN2',
'group': groups[1].pk,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"group,ssid",
"Wireless LAN Group 2,WLAN4",
"Wireless LAN Group 2,WLAN5",
"Wireless LAN Group 2,WLAN6",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = WirelessLink
@classmethod
def setUpTestData(cls):
device = create_test_device('test-device')
interfaces = [
Interface(
device=device,
name=f'radio{i}',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
) for i in range(12)
]
Interface.objects.bulk_create(interfaces)
WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save()
WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save()
WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'interface_a': interfaces[6].pk,
'interface_b': interfaces[7].pk,
'status': LinkStatusChoices.STATUS_PLANNED,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"interface_a,interface_b,status",
f"{interfaces[6].pk},{interfaces[7].pk},connected",
f"{interfaces[8].pk},{interfaces[9].pk},connected",
f"{interfaces[10].pk},{interfaces[11].pk},connected",
)
cls.bulk_edit_data = {
'status': LinkStatusChoices.STATUS_PLANNED,
}

45
netbox/wireless/urls.py Normal file
View File

@ -0,0 +1,45 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import *
app_name = 'wireless'
urlpatterns = (
# Wireless LAN groups
path('wireless-lan-groups/', views.WirelessLANGroupListView.as_view(), name='wirelesslangroup_list'),
path('wireless-lan-groups/add/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_add'),
path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'),
path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'),
path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'),
path('wireless-lan-groups/<int:pk>/', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'),
path('wireless-lan-groups/<int:pk>/edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'),
path('wireless-lan-groups/<int:pk>/delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'),
path('wireless-lan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}),
# Wireless LANs
path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'),
path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'),
path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'),
path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'),
path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'),
path('wireless-lans/<int:pk>/', views.WirelessLANView.as_view(), name='wirelesslan'),
path('wireless-lans/<int:pk>/edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'),
path('wireless-lans/<int:pk>/delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'),
path('wireless-lans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}),
path('wireless-lans/<int:pk>/journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}),
# Wireless links
path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'),
path('wireless-links/add/', views.WirelessLinkEditView.as_view(), name='wirelesslink_add'),
path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'),
path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'),
path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'),
path('wireless-links/<int:pk>/', views.WirelessLinkView.as_view(), name='wirelesslink'),
path('wireless-links/<int:pk>/edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'),
path('wireless-links/<int:pk>/delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'),
path('wireless-links/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}),
path('wireless-links/<int:pk>/journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}),
)

27
netbox/wireless/utils.py Normal file
View File

@ -0,0 +1,27 @@
from decimal import Decimal
from .choices import WirelessChannelChoices
__all__ = (
'get_channel_attr',
)
def get_channel_attr(channel, attr):
"""
Return the specified attribute of a given WirelessChannelChoices value.
"""
if channel not in WirelessChannelChoices.values():
raise ValueError(f"Invalid channel value: {channel}")
channel_values = channel.split('-')
attrs = {
'band': channel_values[0],
'id': int(channel_values[1]),
'frequency': Decimal(channel_values[2]),
'width': Decimal(channel_values[3]),
}
if attr not in attrs:
raise ValueError(f"Invalid channel attribute: {attr}")
return attrs[attr]

177
netbox/wireless/views.py Normal file
View File

@ -0,0 +1,177 @@
from dcim.models import Interface
from netbox.views import generic
from utilities.tables import paginate_table
from utilities.utils import count_related
from . import filtersets, forms, tables
from .models import *
#
# Wireless LAN groups
#
class WirelessLANGroupListView(generic.ObjectListView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
'group',
'wirelesslan_count',
cumulative=True
).prefetch_related('tags')
filterset = filtersets.WirelessLANGroupFilterSet
filterset_form = forms.WirelessLANGroupFilterForm
table = tables.WirelessLANGroupTable
class WirelessLANGroupView(generic.ObjectView):
queryset = WirelessLANGroup.objects.all()
def get_extra_context(self, request, instance):
wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter(
group=instance
)
wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',))
paginate_table(wirelesslans_table, request)
return {
'wirelesslans_table': wirelesslans_table,
}
class WirelessLANGroupEditView(generic.ObjectEditView):
queryset = WirelessLANGroup.objects.all()
model_form = forms.WirelessLANGroupForm
class WirelessLANGroupDeleteView(generic.ObjectDeleteView):
queryset = WirelessLANGroup.objects.all()
class WirelessLANGroupBulkImportView(generic.BulkImportView):
queryset = WirelessLANGroup.objects.all()
model_form = forms.WirelessLANGroupCSVForm
table = tables.WirelessLANGroupTable
class WirelessLANGroupBulkEditView(generic.BulkEditView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
'group',
'wirelesslan_count',
cumulative=True
)
filterset = filtersets.WirelessLANGroupFilterSet
table = tables.WirelessLANGroupTable
form = forms.WirelessLANGroupBulkEditForm
class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
'group',
'wirelesslan_count',
cumulative=True
)
filterset = filtersets.WirelessLANGroupFilterSet
table = tables.WirelessLANGroupTable
#
# Wireless LANs
#
class WirelessLANListView(generic.ObjectListView):
queryset = WirelessLAN.objects.annotate(
interface_count=count_related(Interface, 'wireless_lans')
)
filterset = filtersets.WirelessLANFilterSet
filterset_form = forms.WirelessLANFilterForm
table = tables.WirelessLANTable
class WirelessLANView(generic.ObjectView):
queryset = WirelessLAN.objects.all()
def get_extra_context(self, request, instance):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
paginate_table(interfaces_table, request)
return {
'interfaces_table': interfaces_table,
}
class WirelessLANEditView(generic.ObjectEditView):
queryset = WirelessLAN.objects.all()
model_form = forms.WirelessLANForm
class WirelessLANDeleteView(generic.ObjectDeleteView):
queryset = WirelessLAN.objects.all()
class WirelessLANBulkImportView(generic.BulkImportView):
queryset = WirelessLAN.objects.all()
model_form = forms.WirelessLANCSVForm
table = tables.WirelessLANTable
class WirelessLANBulkEditView(generic.BulkEditView):
queryset = WirelessLAN.objects.all()
filterset = filtersets.WirelessLANFilterSet
table = tables.WirelessLANTable
form = forms.WirelessLANBulkEditForm
class WirelessLANBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLAN.objects.all()
filterset = filtersets.WirelessLANFilterSet
table = tables.WirelessLANTable
#
# Wireless Links
#
class WirelessLinkListView(generic.ObjectListView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
filterset_form = forms.WirelessLinkFilterForm
table = tables.WirelessLinkTable
class WirelessLinkView(generic.ObjectView):
queryset = WirelessLink.objects.all()
class WirelessLinkEditView(generic.ObjectEditView):
queryset = WirelessLink.objects.all()
model_form = forms.WirelessLinkForm
class WirelessLinkDeleteView(generic.ObjectDeleteView):
queryset = WirelessLink.objects.all()
class WirelessLinkBulkImportView(generic.BulkImportView):
queryset = WirelessLink.objects.all()
model_form = forms.WirelessLinkCSVForm
table = tables.WirelessLinkTable
class WirelessLinkBulkEditView(generic.BulkEditView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
table = tables.WirelessLinkTable
form = forms.WirelessLinkBulkEditForm
class WirelessLinkBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
table = tables.WirelessLinkTable