Просмотр исходного кода

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

Closes #3979: Wireless network modeling
Jeremy Stretch 4 лет назад
Родитель
Сommit
334c97035e
87 измененных файлов с 3367 добавлено и 213 удалено
  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.
 
+### 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 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'
         - Service Mapping: 'core-functionality/services.md'
         - Circuits: 'core-functionality/circuits.md'
+        - Wireless: 'core-functionality/wireless.md'
         - Power Tracking: 'core-functionality/power.md'
         - Tenancy: 'core-functionality/tenancy.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.models import *
 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.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
 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')
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -99,6 +99,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer
         model = CircuitTermination
         fields = [
             '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',
         ]

+ 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 dcim.fields import ASNField
-from dcim.models import CableTermination, PathEndpoint
+from dcim.models import LinkTermination, PathEndpoint
 from extras.models import ObjectChange
 from extras.utils import extras_features
 from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
@@ -256,7 +256,7 @@ class Circuit(PrimaryModel):
 
 
 @extras_features('webhooks')
-class CircuitTermination(ChangeLoggedModel, CableTermination):
+class CircuitTermination(ChangeLoggedModel, LinkTermination):
     circuit = models.ForeignKey(
         to='circuits.Circuit',
         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 utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedClusterSerializer
+from wireless.choices 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)
 
-    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
 
     @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']}
-            return serializer(obj._cable_peer, context=context).data
+            return serializer(obj._link_peer, context=context).data
         return None
 
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
@@ -503,7 +504,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
 # Device components
 #
 
-class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -522,12 +523,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
         model = ConsoleServerPort
         fields = [
             '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',
         ]
 
 
-class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -546,12 +547,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
         model = ConsolePort
         fields = [
             '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',
         ]
 
 
-class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -575,12 +576,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
         model = PowerOutlet
         fields = [
             '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',
         ]
 
 
-class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(
@@ -594,18 +595,20 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
         model = PowerPort
         fields = [
             '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',
         ]
 
 
-class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
     parent = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     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)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -620,10 +623,10 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
         model = Interface
         fields = [
             '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):
@@ -640,7 +643,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
         return super().validate(data)
 
 
-class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
@@ -650,7 +653,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
         model = RearPort
         fields = [
             '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',
         ]
 
@@ -666,7 +669,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'label']
 
 
-class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
@@ -677,7 +680,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
         model = FrontPort
         fields = [
             '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',
         ]
 
@@ -728,7 +731,7 @@ class CableSerializer(PrimaryModelSerializer):
     )
     termination_a = 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)
     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']
 
 
-class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
@@ -883,7 +886,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co
         model = PowerFeed
         fields = [
             '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',
             'created', 'last_updated', '_occupied',
         ]

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

@@ -513,7 +513,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
 #
 
 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
     filterset_class = filtersets.ConsolePortFilterSet
     brief_prefetch_fields = ['device']
@@ -521,7 +521,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
 
 class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
-        'device', '_path__destination', 'cable', '_cable_peer', 'tags'
+        'device', '_path__destination', 'cable', '_link_peer', 'tags'
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -529,14 +529,14 @@ class ConsoleServerPortViewSet(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
     filterset_class = filtersets.PowerPortFilterSet
     brief_prefetch_fields = ['device']
 
 
 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
     filterset_class = filtersets.PowerOutletFilterSet
     brief_prefetch_fields = ['device']
@@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
 
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     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
     filterset_class = filtersets.InterfaceFilterSet
@@ -625,7 +625,7 @@ class PowerPanelViewSet(ModelViewSet):
 
 class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
     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
     filterset_class = filtersets.PowerFeedFilterSet

+ 2 - 2
netbox/dcim/choices.py

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

+ 1 - 0
netbox/dcim/constants.py

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

+ 12 - 2
netbox/dcim/filtersets.py

@@ -14,6 +14,7 @@ from utilities.filters import (
     TreeNodeMultipleChoiceFilter,
 )
 from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from .choices import *
 from .constants import *
 from .models import *
@@ -987,10 +988,19 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
         choices=InterfaceTypeChoices,
         null_value=None
     )
+    rf_role = django_filters.MultipleChoiceFilter(
+        choices=WirelessRoleChoices
+    )
+    rf_channel = django_filters.MultipleChoiceFilter(
+        choices=WirelessChannelChoices
+    )
 
     class Meta:
         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):
         try:
@@ -1202,7 +1212,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         choices=CableTypeChoices
     )
     status = django_filters.MultipleChoiceFilter(
-        choices=CableStatusChoices
+        choices=LinkStatusChoices
     )
     color = django_filters.MultipleChoiceFilter(
         choices=ColorChoices

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

@@ -463,7 +463,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
         widget=StaticSelect()
     )
     status = forms.ChoiceField(
-        choices=add_blank_choice(CableStatusChoices),
+        choices=add_blank_choice(LinkStatusChoices),
         required=False,
         widget=StaticSelect(),
         initial=''
@@ -940,7 +940,7 @@ class PowerOutletBulkEditForm(
 class InterfaceBulkEditForm(
     form_from_model(Interface, [
         '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,
     AddRemoveTagsForm,
@@ -991,8 +991,8 @@ class InterfaceBulkEditForm(
 
     class Meta:
         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):

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

@@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
 from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices
 
 __all__ = (
     'CableCSVForm',
@@ -584,12 +585,18 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
         required=False,
         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:
         model = Interface
         fields = (
             '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):
@@ -812,7 +819,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
 
     # Cable attributes
     status = CSVChoiceField(
-        choices=CableStatusChoices,
+        choices=LinkStatusChoices,
         required=False,
         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,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
+from wireless.choices import *
 
 __all__ = (
     'CableFilterForm',
@@ -735,7 +736,7 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
     )
     status = forms.ChoiceField(
         required=False,
-        choices=add_blank_choice(CableStatusChoices),
+        choices=add_blank_choice(LinkStatusChoices),
         widget=StaticSelect()
     )
     color = ColorField(
@@ -966,6 +967,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['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'],
     ]
     kind = forms.MultipleChoiceField(
@@ -998,6 +1000,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
         required=False,
         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)
 
 

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

@@ -16,6 +16,7 @@ from utilities.forms import (
     SlugField, StaticSelect,
 )
 from virtualization.models import Cluster, ClusterGroup
+from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm
 
 __all__ = (
@@ -1100,6 +1101,19 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
             '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(
         queryset=VLANGroup.objects.all(),
         required=False,
@@ -1130,18 +1144,23 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
         model = Interface
         fields = [
             '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 = {
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'mode': StaticSelect(),
+            'rf_role': StaticSelect(),
+            'rf_channel': StaticSelect(),
         }
         labels = {
             'mode': '802.1Q Mode',
         }
         help_texts = {
             '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):

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

@@ -10,6 +10,7 @@ from utilities.forms import (
     add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     ExpandableNameField, StaticSelect,
 )
+from wireless.choices import *
 from .common import InterfaceCommonForm
 
 __all__ = (
@@ -465,7 +466,27 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
     mode = forms.ChoiceField(
         choices=add_blank_choice(InterfaceModeChoices),
         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(),
+        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(
         queryset=VLAN.objects.all(),
@@ -477,7 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
     )
     field_order = (
         '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):

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

@@ -212,6 +212,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
     def resolve_mode(self, info):
         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):
 

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

@@ -1,6 +1,7 @@
 from django.core.management.base import BaseCommand
 from django.core.management.color import no_style
 from django.db import connection
+from django.db.models import Q
 
 from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
 from dcim.signals import create_cablepath
@@ -67,7 +68,10 @@ class Command(BaseCommand):
 
         # Retrace paths
         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']:
                 origins = origins.filter(_path__isnull=True)
             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',
     'Cable',
     'CablePath',
-    'CableTermination',
+    'LinkTermination',
     'ConsolePort',
     'ConsolePortTemplate',
     'ConsoleServerPort',

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

@@ -64,8 +64,8 @@ class Cable(PrimaryModel):
     )
     status = models.CharField(
         max_length=50,
-        choices=CableStatusChoices,
-        default=CableStatusChoices.STATUS_CONNECTED
+        choices=LinkStatusChoices,
+        default=LinkStatusChoices.STATUS_CONNECTED
     )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
@@ -292,7 +292,7 @@ class Cable(PrimaryModel):
         self._pk = self.pk
 
     def get_status_class(self):
-        return CableStatusChoices.CSS_CLASSES.get(self.status)
+        return LinkStatusChoices.CSS_CLASSES.get(self.status)
 
     def get_compatible_types(self):
         """
@@ -386,7 +386,7 @@ class CablePath(BigIDModel):
         """
         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
 
         destination = None
@@ -396,13 +396,13 @@ class CablePath(BigIDModel):
         is_split = False
 
         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
 
-            # 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
             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.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
+from wireless.choices import *
+from wireless.utils import get_channel_attr
 
 
 __all__ = (
     'BaseInterface',
-    'CableTermination',
+    'LinkTermination',
     'ConsolePort',
     'ConsoleServerPort',
     'DeviceBay',
@@ -87,14 +89,14 @@ class ComponentModel(PrimaryModel):
         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(
         to='dcim.Cable',
@@ -103,20 +105,20 @@ class CableTermination(models.Model):
         blank=True,
         null=True
     )
-    _cable_peer_type = models.ForeignKey(
+    _link_peer_type = models.ForeignKey(
         to=ContentType,
         on_delete=models.SET_NULL,
         related_name='+',
         blank=True,
         null=True
     )
-    _cable_peer_id = models.PositiveIntegerField(
+    _link_peer_id = models.PositiveIntegerField(
         blank=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(
         default=False,
@@ -146,8 +148,8 @@ class CableTermination(models.Model):
                 "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
     def _occupied(self):
@@ -157,6 +159,13 @@ class CableTermination(models.Model):
     def parent_object(self):
         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):
     """
@@ -219,7 +228,7 @@ class PathEndpoint(models.Model):
 #
 
 @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.
     """
@@ -251,7 +260,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
 #
 
 @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.
     """
@@ -283,7 +292,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
 #
 
 @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.
     """
@@ -333,8 +342,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
             poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
             outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
             utilization = PowerPort.objects.filter(
-                _cable_peer_type=poweroutlet_ct,
-                _cable_peer_id__in=outlet_ids
+                _link_peer_type=poweroutlet_ct,
+                _link_peer_id__in=outlet_ids
             ).aggregate(
                 maximum_draw_total=Sum('maximum_draw'),
                 allocated_draw_total=Sum('allocated_draw'),
@@ -347,12 +356,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
             }
 
             # 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:
                     outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
                     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(
                         maximum_draw_total=Sum('maximum_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')
-class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
+class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
     """
     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')
-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.
     """
@@ -517,6 +526,45 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
         verbose_name='WWN',
         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(
         to='ipam.VLAN',
         on_delete=models.SET_NULL,
@@ -550,14 +598,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     def clean(self):
         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({
                 '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({
                 '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:
             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
         if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
             raise ValidationError({
@@ -611,8 +687,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
             })
 
     @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
     def is_virtual(self):
@@ -626,13 +706,17 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     def is_lag(self):
         return self.type == InterfaceTypeChoices.TYPE_LAG
 
+    @property
+    def link(self):
+        return self.cable or self.wireless_link
+
 
 #
 # Pass-through ports
 #
 
 @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.
     """
@@ -686,7 +770,7 @@ class FrontPort(ComponentModel, CableTermination):
 
 
 @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.
     """

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

@@ -10,7 +10,7 @@ from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.validators import ExclusionValidator
-from .device_components import CableTermination, PathEndpoint
+from .device_components import LinkTermination, PathEndpoint
 
 __all__ = (
     'PowerFeed',
@@ -72,7 +72,7 @@ class PowerPanel(PrimaryModel):
 
 
 @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.
     """

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

@@ -427,13 +427,13 @@ class Rack(PrimaryModel):
             return 0
 
         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)
         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
 
         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.db.models.signals import post_save, post_delete, pre_delete
-from django.db import transaction
 from django.dispatch import receiver
 
-from .choices import CableStatusChoices
+from .choices import LinkStatusChoices
 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:
         logger.debug(f"Updating termination A for 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()
     if instance.termination_b.cable != instance:
         logger.debug(f"Updating termination B for 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()
 
     # 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
         # may change in the future.) However, we do need to capture status changes and update
         # 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)
         else:
             rebuild_paths(instance)
@@ -145,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs):
     if instance.termination_a is not None:
         logger.debug(f"Nullifying termination A for cable {instance}")
         model = instance.termination_a._meta.model
-        model.objects.filter(pk=instance.termination_a.pk).update(_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:
         logger.debug(f"Nullifying termination B for cable {instance}")
         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
     for cablepath in CablePath.objects.filter(path__contains=instance):

+ 66 - 15
netbox/dcim/svg.py

@@ -398,6 +398,39 @@ class CableTraceSVG:
 
         return group
 
+    def _draw_wirelesslink(self, url, labels):
+        """
+        Draw a line with labels representing a WirelessLink.
+
+        :param url: Hyperlink URL
+        :param labels: Iterable of text labels
+        """
+        group = Group(class_='connector')
+
+        # Draw the wireless link
+        start = (OFFSET + self.center, self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+        end = (start[0], start[1] + height)
+        line = Line(start=start, end=end, class_='wireless-link')
+        group.add(line)
+
+        self.cursor += PADDING * 2
+
+        # Add link
+        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+        # Add text label(s)
+        for i, label in enumerate(labels):
+            self.cursor += LINE_HEIGHT
+            text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
+            text = Text(label, insert=text_coords, class_='bold' if not i else [])
+            link.add(text)
+
+        group.add(link)
+        self.cursor += PADDING * 2
+
+        return group
+
     def _draw_attachment(self):
         """
         Return an SVG group containing a line element and "Attachment" label.
@@ -418,6 +451,9 @@ class CableTraceSVG:
         """
         Return an SVG document representing a cable trace.
         """
+        from dcim.models import Cable
+        from wireless.models import WirelessLink
+
         traced_path = self.origin.trace()
 
         # Prep elements list
@@ -452,24 +488,39 @@ class CableTraceSVG:
             )
             terminations.append(termination)
 
-            # Connector (either a Cable or attachment to a ProviderNetwork)
+            # Connector (a Cable or WirelessLink)
             if connector is not None:
 
                 # 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
                 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,
     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__ = (
     'BaseInterfaceTable',
@@ -266,11 +262,11 @@ class CableTerminationTable(BaseTable):
         orderable=False,
         verbose_name='Cable Color'
     )
-    cable_peer = TemplateColumn(
-        accessor='_cable_peer',
-        template_code=CABLETERMINATION,
+    link_peer = TemplateColumn(
+        accessor='_link_peer',
+        template_code=LINKTERMINATION,
         orderable=False,
-        verbose_name='Cable Peer'
+        verbose_name='Link Peer'
     )
     mark_connected = BooleanColumn()
 
@@ -278,7 +274,7 @@ class CableTerminationTable(BaseTable):
 class PathEndpointTable(CableTerminationTable):
     connection = TemplateColumn(
         accessor='_path.last_node',
-        template_code=CABLETERMINATION,
+        template_code=LINKTERMINATION,
         verbose_name='Connection',
         orderable=False
     )
@@ -299,7 +295,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
         model = ConsolePort
         fields = (
             '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')
 
@@ -320,7 +316,7 @@ class DeviceConsolePortTable(ConsolePortTable):
         model = ConsolePort
         fields = (
             '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')
         row_attrs = {
@@ -343,7 +339,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
         model = ConsoleServerPort
         fields = (
             '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')
 
@@ -365,7 +361,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
         model = ConsoleServerPort
         fields = (
             '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')
         row_attrs = {
@@ -388,7 +384,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
         model = PowerPort
         fields = (
             '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')
 
@@ -410,7 +406,7 @@ class DevicePowerPortTable(PowerPortTable):
         model = PowerPort
         fields = (
             '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 = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -439,7 +435,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
         model = PowerOutlet
         fields = (
             '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')
 
@@ -460,7 +456,7 @@ class DevicePowerOutletTable(PowerOutletTable):
         model = PowerOutlet
         fields = (
             '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 = (
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@@ -493,6 +489,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
         }
     )
     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(
         url_name='dcim:interface_list'
     )
@@ -501,7 +505,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
         model = Interface
         fields = (
             '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',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -509,8 +514,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
 
 class DeviceInterfaceTable(InterfaceTable):
     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>',
         order_by=Accessor('_name'),
         attrs={'td': {'class': 'text-nowrap'}}
@@ -533,8 +538,9 @@ class DeviceInterfaceTable(InterfaceTable):
         model = Interface
         fields = (
             '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',)
         default_columns = (
@@ -570,7 +576,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
         model = FrontPort
         fields = (
             '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 = (
             'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -594,10 +600,10 @@ class DeviceFrontPortTable(FrontPortTable):
         model = FrontPort
         fields = (
             '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 = (
-            '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',
         )
         row_attrs = {
@@ -621,7 +627,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
         model = RearPort
         fields = (
             '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')
 
@@ -643,10 +649,10 @@ class DeviceRearPortTable(RearPortTable):
         model = RearPort
         fields = (
             'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
-            'cable_peer', 'tags', 'actions',
+            'link_peer', 'tags', 'actions',
         )
         default_columns = (
-            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
+            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
         )
         row_attrs = {
             'class': get_cabletermination_row_class

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

@@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable):
         model = PowerFeed
         fields = (
             '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',
         )
         default_columns = (
             '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.parent_object %}
     <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
@@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """
 {% endif %}
 """
 
+INTERFACE_WIRELESS_LANS = """
+{% for wlan in record.wireless_lans.all %}
+  <a href="{{ wlan.get_absolute_url }}">{{ wlan }}</a><br />
+{% endfor %}
+"""
+
 POWERFEED_CABLE = """
 <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">
@@ -195,15 +201,23 @@ INTERFACE_BUTTONS = """
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
     </a>
 {% 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>
+{% endif %}
+{% if record.cable %}
     {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.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">
             <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
         </a>
     {% 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-lan-connect" aria-hidden="true"></i></a>
     {% if not record.mark_connected %}
@@ -221,6 +235,10 @@ INTERFACE_BUTTONS = """
     {% else %}
         <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
     {% endif %}
+{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
+    <a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device.pk }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
+        <span class="mdi mdi-wifi-plus" aria-hidden="true"></span>
+    </a>
 {% 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 circuits.models import *
-from dcim.choices import CableStatusChoices
+from dcim.choices import LinkStatusChoices
 from dcim.models import *
 from dcim.utils import object_to_path_node
 
@@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase):
         self.assertEqual(CablePath.objects.count(), 2)
 
         # Change cable 2's status to "planned"
-        cable2.status = CableStatusChoices.STATUS_PLANNED
+        cable2.status = LinkStatusChoices.STATUS_PLANNED
         cable2.save()
         self.assertPathExists(
             origin=interface1,
@@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase):
 
         # Change cable 2's status to "connected"
         cable2 = Cable.objects.get(pk=cable2.pk)
-        cable2.status = CableStatusChoices.STATUS_CONNECTED
+        cable2.status = LinkStatusChoices.STATUS_CONNECTED
         cable2.save()
         self.assertPathExists(
             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.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterType
+from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 
 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 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 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)
 
@@ -2083,11 +2086,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'connected': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
         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'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
@@ -2099,7 +2102,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mgmt_only': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
         params = {'mode': InterfaceModeChoices.MODE_ACCESS}
@@ -2176,7 +2179,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'cabled': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
         params = {'kind': 'physical'}
@@ -2192,6 +2195,22 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
         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):
     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')
 
         # 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()
 
     def test_label(self):
@@ -2889,9 +2908,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_status(self):
-        params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
+        params = {'status': [LinkStatusChoices.STATUS_CONNECTED]}
         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)
 
     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)
         interface2 = Interface.objects.get(pk=self.interface2.pk)
         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(interface2._cable_peer, interface1)
+        self.assertEqual(interface2._link_peer, interface1)
 
     def test_cable_deletion(self):
         """
@@ -508,10 +508,10 @@ class CableTestCase(TestCase):
         self.assertNotEqual(str(self.cable), '#None')
         interface1 = Interface.objects.get(pk=self.interface1.pk)
         self.assertIsNone(interface1.cable)
-        self.assertIsNone(interface1._cable_peer)
+        self.assertIsNone(interface1._link_peer)
         interface2 = Interface.objects.get(pk=self.interface2.pk)
         self.assertIsNone(interface2.cable)
-        self.assertIsNone(interface2._cable_peer)
+        self.assertIsNone(interface2._link_peer)
 
     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_id': interfaces[3].pk,
             'type': CableTypeChoices.TYPE_CAT6,
-            'status': CableStatusChoices.STATUS_PLANNED,
+            'status': LinkStatusChoices.STATUS_PLANNED,
             'label': 'Label',
             'color': 'c0c0c0',
             'length': 100,
@@ -1961,7 +1961,7 @@ class CableTestCase(
 
         cls.bulk_edit_data = {
             'type': CableTypeChoices.TYPE_CAT5E,
-            'status': CableStatusChoices.STATUS_CONNECTED,
+            'status': LinkStatusChoices.STATUS_CONNECTED,
             'label': 'New label',
             'color': '00ff00',
             'length': 50,

+ 27 - 0
netbox/dcim/utils.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
 
 
 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 = ContentType.objects.get_for_id(ct_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)),
             ('users', reverse('users-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 users.graphql.schema import UsersQuery
 from virtualization.graphql.schema import VirtualizationQuery
+from wireless.graphql.schema import WirelessQuery
 
 
 class Query(
@@ -17,6 +18,7 @@ class Query(
     TenancyQuery,
     UsersQuery,
     VirtualizationQuery,
+    WirelessQuery,
     graphene.ObjectType
 ):
     pass

+ 16 - 0
netbox/netbox/navigation_menu.py

@@ -176,6 +176,7 @@ CONNECTIONS_MENU = Menu(
             label='Connections',
             items=(
                 get_model_item('dcim', 'cable', 'Cables', actions=['import']),
+                get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
                 MenuItem(
                     link='dcim:interface_connections_list',
                     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(
     label='IPAM',
     icon_class='mdi mdi-counter',
@@ -351,6 +366,7 @@ MENUS = [
     ORGANIZATION_MENU,
     DEVICES_MENU,
     CONNECTIONS_MENU,
+    WIRELESS_MENU,
     IPAM_MENU,
     VIRTUALIZATION_MENU,
     CIRCUITS_MENU,

+ 1 - 0
netbox/netbox/settings.py

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

+ 2 - 0
netbox/netbox/urls.py

@@ -48,6 +48,7 @@ _patterns = [
     path('tenancy/', include('tenancy.urls')),
     path('user/', include('users.urls')),
     path('virtualization/', include('virtualization.urls')),
+    path('wireless/', include('wireless.urls')),
 
     # API
     path('api/', APIRootView.as_view(), name='api-root'),
@@ -58,6 +59,7 @@ _patterns = [
     path('api/tenancy/', include('tenancy.api.urls')),
     path('api/users/', include('users.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/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'),

+ 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 tenancy.models import Tenant
 from virtualization.models import Cluster, VirtualMachine
+from wireless.models import WirelessLAN, WirelessLink
 
 
 class HomeView(View):
@@ -92,14 +93,19 @@ class HomeView(View):
                 ("dcim.view_powerpanel", "Power Panels", PowerPanel.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 = (
                 ("Organization", org, "domain"),
                 ("IPAM", ipam, "counter"),
                 ("Virtualization", virtualization, "monitor"),
                 ("Inventory", dcim, "server"),
-                ("Connections", connections, "cable-data"),
                 ("Circuits", circuits, "transit-connection-variant"),
+                ("Connections", connections, "cable-data"),
                 ("Power", power, "flash"),
+                ("Wireless", wireless, "wifi"),
             )
 
             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-width: 7px;
   }
+  line.wireless-link {
+    stroke: var(--nbx-trace-attachment);
+    stroke-dasharray: 4px 12px;
+    stroke-linecap: round;
+  }
   line.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>
                 {% elif termination.cable %}
                   <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
                     {% if peer.device %}
                       <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 %}
         </div>
         <div class="col col-md-6">
-            {% if object.is_connectable %}
+            {% if not object.is_virtual %}
                 <div class="card">
                     <h5 class="card-header">
                         Connection
@@ -211,10 +211,40 @@
                                 </td>
                             </tr>
                         </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 %}
                         <div class="text-muted">
                             Not Connected
-                            {% if perms.dcim.add_cable %}
+                            {% if object.is_wired and perms.dcim.add_cable %}
                                 <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">
                                         <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
@@ -242,12 +272,125 @@
                                         </li>
                                     </ul>
                                 </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 %}
                         </div>
                     {% endif %}
                     </div>
                 </div>
             {% 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 %}
                 <div class="card">
                     <h5 class="card-header">LAG Members</h5>

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

@@ -29,6 +29,20 @@
         {% render_field form.mark_connected %}
     </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="row mb-2">
           <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 decimal
 import json
 import re
 from typing import Dict, Any
@@ -146,6 +147,19 @@ def humanize_megabytes(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()
 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