Browse Source

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

Closes #3979: Wireless network modeling
Jeremy Stretch 4 năm trước cách đây
mục cha
commit
334c97035e
87 tập tin đã thay đổi với 3367 bổ sung213 xóa
  1. 8 0
      docs/core-functionality/wireless.md
  2. 11 0
      docs/models/dcim/interface.md
  3. 11 0
      docs/models/wireless/wirelesslan.md
  4. 3 0
      docs/models/wireless/wirelesslangroup.md
  5. 9 0
      docs/models/wireless/wirelesslink.md
  6. 1 0
      mkdocs.yml
  7. 3 3
      netbox/circuits/api/serializers.py
  8. 21 0
      netbox/circuits/migrations/0004_rename_cable_peer.py
  9. 2 2
      netbox/circuits/models.py
  10. 34 31
      netbox/dcim/api/serializers.py
  11. 6 6
      netbox/dcim/api/views.py
  12. 2 2
      netbox/dcim/choices.py
  13. 1 0
      netbox/dcim/constants.py
  14. 12 2
      netbox/dcim/filtersets.py
  15. 4 4
      netbox/dcim/forms/bulk_edit.py
  16. 9 2
      netbox/dcim/forms/bulk_import.py
  17. 23 1
      netbox/dcim/forms/filtersets.py
  18. 20 1
      netbox/dcim/forms/models.py
  19. 23 1
      netbox/dcim/forms/object_create.py
  20. 6 0
      netbox/dcim/graphql/types.py
  21. 5 1
      netbox/dcim/management/commands/trace_paths.py
  22. 91 0
      netbox/dcim/migrations/0139_rename_cable_peer.py
  23. 43 0
      netbox/dcim/migrations/0140_wireless.py
  24. 1 1
      netbox/dcim/models/__init__.py
  25. 9 9
      netbox/dcim/models/cables.py
  26. 116 32
      netbox/dcim/models/device_components.py
  27. 2 2
      netbox/dcim/models/power.py
  28. 4 4
      netbox/dcim/models/racks.py
  29. 7 33
      netbox/dcim/signals.py
  30. 66 15
      netbox/dcim/svg.py
  31. 35 29
      netbox/dcim/tables/devices.py
  32. 2 2
      netbox/dcim/tables/power.py
  33. 21 3
      netbox/dcim/tables/template_code.py
  34. 3 3
      netbox/dcim/tests/test_cablepaths.py
  35. 31 12
      netbox/dcim/tests/test_filtersets.py
  36. 4 4
      netbox/dcim/tests/test_models.py
  37. 2 2
      netbox/dcim/tests/test_views.py
  38. 27 0
      netbox/dcim/utils.py
  39. 1 0
      netbox/netbox/api/views.py
  40. 2 0
      netbox/netbox/graphql/schema.py
  41. 16 0
      netbox/netbox/navigation_menu.py
  42. 1 0
      netbox/netbox/settings.py
  43. 2 0
      netbox/netbox/urls.py
  44. 7 1
      netbox/netbox/views/__init__.py
  45. 1 1
      netbox/project-static/dist/cable_trace.css
  46. 6 1
      netbox/project-static/styles/cable-trace.scss
  47. 1 1
      netbox/templates/circuits/inc/circuit_termination.html
  48. 145 2
      netbox/templates/dcim/interface.html
  49. 14 0
      netbox/templates/dcim/interface_edit.html
  50. 21 0
      netbox/templates/wireless/inc/authentication_attrs.html
  51. 54 0
      netbox/templates/wireless/inc/wirelesslink_interface.html
  52. 64 0
      netbox/templates/wireless/wirelesslan.html
  53. 73 0
      netbox/templates/wireless/wirelesslangroup.html
  54. 55 0
      netbox/templates/wireless/wirelesslink.html
  55. 33 0
      netbox/templates/wireless/wirelesslink_edit.html
  56. 14 0
      netbox/utilities/templatetags/helpers.py
  57. 0 0
      netbox/wireless/__init__.py
  58. 0 0
      netbox/wireless/api/__init__.py
  59. 36 0
      netbox/wireless/api/nested_serializers.py
  60. 59 0
      netbox/wireless/api/serializers.py
  61. 13 0
      netbox/wireless/api/urls.py
  62. 38 0
      netbox/wireless/api/views.py
  63. 8 0
      netbox/wireless/apps.py
  64. 191 0
      netbox/wireless/choices.py
  65. 2 0
      netbox/wireless/constants.py
  66. 102 0
      netbox/wireless/filtersets.py
  67. 4 0
      netbox/wireless/forms/__init__.py
  68. 101 0
      netbox/wireless/forms/bulk_edit.py
  69. 83 0
      netbox/wireless/forms/bulk_import.py
  70. 104 0
      netbox/wireless/forms/filtersets.py
  71. 166 0
      netbox/wireless/forms/models.py
  72. 0 0
      netbox/wireless/graphql/__init__.py
  73. 15 0
      netbox/wireless/graphql/schema.py
  74. 44 0
      netbox/wireless/graphql/types.py
  75. 80 0
      netbox/wireless/migrations/0001_wireless.py
  76. 41 0
      netbox/wireless/migrations/0002_wireless_auth.py
  77. 0 0
      netbox/wireless/migrations/__init__.py
  78. 209 0
      netbox/wireless/models.py
  79. 66 0
      netbox/wireless/signals.py
  80. 110 0
      netbox/wireless/tables.py
  81. 0 0
      netbox/wireless/tests/__init__.py
  82. 141 0
      netbox/wireless/tests/test_api.py
  83. 194 0
      netbox/wireless/tests/test_filtersets.py
  84. 123 0
      netbox/wireless/tests/test_views.py
  85. 45 0
      netbox/wireless/urls.py
  86. 27 0
      netbox/wireless/utils.py
  87. 177 0
      netbox/wireless/views.py

+ 8 - 0
docs/core-functionality/wireless.md

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

+ 11 - 0
docs/models/dcim/interface.md

@@ -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.)

+ 11 - 0
docs/models/wireless/wirelesslan.md

@@ -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

+ 3 - 0
docs/models/wireless/wirelesslangroup.md

@@ -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.

+ 9 - 0
docs/models/wireless/wirelesslink.md

@@ -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

+ 1 - 0
mkdocs.yml

@@ -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'

+ 3 - 3
netbox/circuits/api/serializers.py

@@ -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',
         ]
         ]

+ 21 - 0
netbox/circuits/migrations/0004_rename_cable_peer.py

@@ -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',
+        ),
+    ]

+ 2 - 2
netbox/circuits/models.py

@@ -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,

+ 34 - 31
netbox/dcim/api/serializers.py

@@ -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):
-    cable_peer_type = serializers.SerializerMethodField(read_only=True)
-    cable_peer = serializers.SerializerMethodField(read_only=True)
+class LinkTerminationSerializer(serializers.ModelSerializer):
+    link_peer_type = 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):
-        if obj._cable_peer is not None:
-            return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
+    def get_link_peer_type(self, obj):
+        if obj._link_peer is not None:
+            return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
         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:
-            serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
+        if obj._link_peer is not None:
+            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',
-            'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
-            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
-            '_occupied',
+            'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer',
+            'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
+            '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',
         ]
         ]

+ 6 - 6
netbox/dcim/api/views.py

@@ -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

+ 2 - 2
netbox/dcim/choices.py

@@ -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'

+ 1 - 0
netbox/dcim/constants.py

@@ -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

+ 12 - 2
netbox/dcim/filtersets.py

@@ -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

+ 4 - 4
netbox/dcim/forms/bulk_edit.py

@@ -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',
-            'tagged_vlans',
+            'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 9 - 2
netbox/dcim/forms/bulk_import.py

@@ -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'
     )
     )

+ 23 - 1
netbox/dcim/forms/filtersets.py

@@ -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)
 
 
 
 

+ 20 - 1
netbox/dcim/forms/models.py

@@ -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):

+ 23 - 1
netbox/dcim/forms/object_create.py

@@ -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(),
+        label='Wireless role'
+    )
+    rf_channel = forms.ChoiceField(
+        choices=add_blank_choice(WirelessChannelChoices),
+        required=False,
         widget=StaticSelect(),
         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):

+ 6 - 0
netbox/dcim/graphql/types.py

@@ -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):
 
 

+ 5 - 1
netbox/dcim/management/commands/trace_paths.py

@@ -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()

+ 91 - 0
netbox/dcim/migrations/0139_rename_cable_peer.py

@@ -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',
+        ),
+    ]

+ 43 - 0
netbox/dcim/migrations/0140_wireless.py

@@ -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'),
+        ),
+    ]

+ 1 - 1
netbox/dcim/models/__init__.py

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

+ 9 - 9
netbox/dcim/models/cables.py

@@ -64,8 +64,8 @@ class Cable(PrimaryModel):
     )
     )
     status = models.CharField(
     status = models.CharField(
         max_length=50,
         max_length=50,
-        choices=CableStatusChoices,
-        default=CableStatusChoices.STATUS_CONNECTED
+        choices=LinkStatusChoices,
+        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:
-            if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
+        while node.link is not None:
+            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
-            path.append(object_to_path_node(node.cable))
-            peer_termination = node.get_cable_peer()
+            # Follow the link to its far-end termination
+            path.append(object_to_path_node(node.link))
+            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):

+ 116 - 32
netbox/dcim/models/device_components.py

@@ -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
-    CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
+    An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
+    include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
+    reference the attached Cable or WirelessLink instance, respectively.
 
 
-    `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination 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
-    dcim.signals when a Cable instance is created or deleted, respectively.
+    `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
+    shortcut to referencing `instance.link.termination_b`, for example.
     """
     """
     cable = models.ForeignKey(
     cable = models.ForeignKey(
         to='dcim.Cable',
         to='dcim.Cable',
@@ -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(
-        ct_field='_cable_peer_type',
-        fk_field='_cable_peer_id'
+    _link_peer = GenericForeignKey(
+        ct_field='_link_peer_type',
+        fk_field='_link_peer_id'
     )
     )
     mark_connected = models.BooleanField(
     mark_connected = models.BooleanField(
         default=False,
         default=False,
@@ -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):
-        return self._cable_peer
+    def get_link_peer(self):
+        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,
-                _cable_peer_id__in=outlet_ids
+                _link_peer_type=poweroutlet_ct,
+                _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,
-                        _cable_peer_id__in=outlet_ids
+                        _link_peer_type=poweroutlet_ct,
+                        _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
-        if not self.is_connectable and self.cable:
+        # Virtual Interfaces cannot have a Cable attached
+        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
-        if not self.is_connectable and self.mark_connected:
+        # Virtual Interfaces cannot be marked as 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):
-        return self.type not in NONCONNECTABLE_IFACE_TYPES
+    def _occupied(self):
+        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.
     """
     """

+ 2 - 2
netbox/dcim/models/power.py

@@ -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.
     """
     """

+ 4 - 4
netbox/dcim/models/racks.py

@@ -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),
-            _cable_peer_id__in=powerfeeds.values_list('id', flat=True)
+            _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
+            _link_peer_id__in=powerfeeds.values_list('id', flat=True)
         )
         )
         poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
         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),
-            _cable_peer_id__in=poweroutlets.values_list('id', flat=True)
+            _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
+            _link_peer_id__in=poweroutlets.values_list('id', flat=True)
         ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
         ).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)

+ 7 - 33
netbox/dcim/signals.py

@@ -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
-
-
-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)
+from .utils import create_cablepath, rebuild_paths
 
 
 
 
 #
 #
@@ -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):

+ 66 - 15
netbox/dcim/svg.py

@@ -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 = [
-                    f'Cable {connector}',
-                    connector.get_status_display()
-                ]
-                if connector.type:
-                    cable_labels.append(connector.get_type_display())
-                if connector.length and connector.length_unit:
-                    cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
-                cable = self._draw_cable(
-                    color=connector.color or '000000',
-                    url=connector.get_absolute_url(),
-                    labels=cable_labels
-                )
-                connectors.append(cable)
+                if type(connector) is Cable:
+                    connector_labels = [
+                        f'Cable {connector}',
+                        connector.get_status_display()
+                    ]
+                    if connector.type:
+                        connector_labels.append(connector.get_type_display())
+                    if connector.length and connector.length_unit:
+                        connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
+                    cable = self._draw_cable(
+                        color=connector.color or '000000',
+                        url=connector.get_absolute_url(),
+                        labels=connector_labels
+                    )
+                    connectors.append(cable)
+
+                # WirelessLink
+                elif type(connector) is WirelessLink:
+                    connector_labels = [
+                        f'Wireless link {connector}',
+                        connector.get_status_display()
+                    ]
+                    if connector.ssid:
+                        connector_labels.append(connector.ssid)
+                    wirelesslink = self._draw_wirelesslink(
+                        url=connector.get_absolute_url(),
+                        labels=connector_labels
+                    )
+                    connectors.append(wirelesslink)
 
 
                 # Far end termination
                 # Far end termination
                 termination = self._draw_box(
                 termination = self._draw_box(

+ 35 - 29
netbox/dcim/tables/devices.py

@@ -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 (
-    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,
-)
+from .template_code import *
 
 
 __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(
-        accessor='_cable_peer',
-        template_code=CABLETERMINATION,
+    link_peer = TemplateColumn(
+        accessor='_link_peer',
+        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'
-                      '{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
+        template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
+                      '{% 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',
-            'untagged_vlan', 'tagged_vlans', 'actions',
+            'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color',
+            '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

+ 2 - 2
netbox/dcim/tables/power.py

@@ -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',
         )
         )

+ 21 - 3
netbox/dcim/tables/template_code.py

@@ -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 %}
 """
 """
 
 

+ 3 - 3
netbox/dcim/tests/test_cablepaths.py

@@ -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,

+ 31 - 12
netbox/dcim/tests/test_filtersets.py

@@ -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[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[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[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[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[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[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+        Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
         Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
         Cable(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):

+ 4 - 4
netbox/dcim/tests/test_models.py

@@ -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):
         """
         """

+ 2 - 2
netbox/dcim/tests/test_views.py

@@ -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,

+ 27 - 0
netbox/dcim/utils.py

@@ -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)

+ 1 - 0
netbox/netbox/api/views.py

@@ -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)),
         )))
         )))
 
 
 
 

+ 2 - 0
netbox/netbox/graphql/schema.py

@@ -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

+ 16 - 0
netbox/netbox/navigation_menu.py

@@ -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,

+ 1 - 0
netbox/netbox/settings.py

@@ -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',
 ]
 ]

+ 2 - 0
netbox/netbox/urls.py

@@ -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'),

+ 7 - 1
netbox/netbox/views/__init__.py

@@ -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 = []

+ 1 - 1
netbox/project-static/dist/cable_trace.css

@@ -1 +1 @@
-:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px,5px}
+:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}

+ 6 - 1
netbox/project-static/styles/cable-trace.scss

@@ -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;
   }
   }
 }
 }

+ 1 - 1
netbox/templates/circuits/inc/circuit_termination.html

@@ -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/>

+ 145 - 2
netbox/templates/dcim/interface.html

@@ -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>

+ 14 - 0
netbox/templates/dcim/interface_edit.html

@@ -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>

+ 21 - 0
netbox/templates/wireless/inc/authentication_attrs.html

@@ -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>

+ 54 - 0
netbox/templates/wireless/inc/wirelesslink_interface.html

@@ -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>

+ 64 - 0
netbox/templates/wireless/wirelesslan.html

@@ -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 %}

+ 73 - 0
netbox/templates/wireless/wirelesslangroup.html

@@ -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 %}

+ 55 - 0
netbox/templates/wireless/wirelesslink.html

@@ -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 %}

+ 33 - 0
netbox/templates/wireless/wirelesslink_edit.html

@@ -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 %}

+ 14 - 0
netbox/utilities/templatetags/helpers.py

@@ -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):
     """
     """

+ 0 - 0
netbox/wireless/__init__.py


+ 0 - 0
netbox/wireless/api/__init__.py


+ 36 - 0
netbox/wireless/api/nested_serializers.py

@@ -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']

+ 59 - 0
netbox/wireless/api/serializers.py

@@ -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',
+        ]

+ 13 - 0
netbox/wireless/api/urls.py

@@ -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

+ 38 - 0
netbox/wireless/api/views.py

@@ -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 - 0
netbox/wireless/apps.py

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

+ 191 - 0
netbox/wireless/choices.py

@@ -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'),
+    )

+ 2 - 0
netbox/wireless/constants.py

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

+ 102 - 0
netbox/wireless/filtersets.py

@@ -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)

+ 4 - 0
netbox/wireless/forms/__init__.py

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

+ 101 - 0
netbox/wireless/forms/bulk_edit.py

@@ -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']

+ 83 - 0
netbox/wireless/forms/bulk_import.py

@@ -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')

+ 104 - 0
netbox/wireless/forms/filtersets.py

@@ -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)

+ 166 - 0
netbox/wireless/forms/models.py

@@ -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',
+        }

+ 0 - 0
netbox/wireless/graphql/__init__.py


+ 15 - 0
netbox/wireless/graphql/schema.py

@@ -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)

+ 44 - 0
netbox/wireless/graphql/types.py

@@ -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

+ 80 - 0
netbox/wireless/migrations/0001_wireless.py

@@ -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')},
+            },
+        ),
+    ]

+ 41 - 0
netbox/wireless/migrations/0002_wireless_auth.py

@@ -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),
+        ),
+    ]

+ 0 - 0
netbox/wireless/migrations/__init__.py


+ 209 - 0
netbox/wireless/models.py

@@ -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)

+ 66 - 0
netbox/wireless/signals.py

@@ -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 - 0
netbox/wireless/tables.py

@@ -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',
+        )

+ 0 - 0
netbox/wireless/tests/__init__.py


+ 141 - 0
netbox/wireless/tests/test_api.py

@@ -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',
+            },
+        ]

+ 194 - 0
netbox/wireless/tests/test_filtersets.py

@@ -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)

+ 123 - 0
netbox/wireless/tests/test_views.py

@@ -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 - 0
netbox/wireless/urls.py

@@ -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 - 0
netbox/wireless/utils.py

@@ -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 - 0
netbox/wireless/views.py

@@ -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