jeremystretch пре 4 година
родитељ
комит
90e9f34494

+ 2 - 6
netbox/dcim/migrations/0136_wireless.py

@@ -1,10 +1,11 @@
+# Generated by Django 3.2.8 on 2021-10-13 13:44
+
 from django.db import migrations, models
 from django.db import migrations, models
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('wireless', '0001_initial'),
         ('dcim', '0135_location_tenant'),
         ('dcim', '0135_location_tenant'),
     ]
     ]
 
 
@@ -19,9 +20,4 @@ class Migration(migrations.Migration):
             name='rf_channel_width',
             name='rf_channel_width',
             field=models.PositiveSmallIntegerField(blank=True, null=True),
             field=models.PositiveSmallIntegerField(blank=True, null=True),
         ),
         ),
-        migrations.AddField(
-            model_name='interface',
-            name='wireless_lans',
-            field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
-        ),
     ]
     ]

+ 19 - 0
netbox/dcim/migrations/0137_wireless.py

@@ -0,0 +1,19 @@
+# Generated by Django 3.2.8 on 2021-10-13 13:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0136_wireless'),
+        ('wireless', '0001_wireless'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='wireless_lans',
+            field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
+        ),
+    ]

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -196,6 +196,7 @@ WIRELESS_MENU = Menu(
             label='Wireless',
             label='Wireless',
             items=(
             items=(
                 get_model_item('wireless', 'wirelesslan', 'Wireless LANs'),
                 get_model_item('wireless', 'wirelesslan', 'Wireless LANs'),
+                get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
             ),
             ),
         ),
         ),
     ),
     ),

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

@@ -0,0 +1,20 @@
+<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>
+</table>

+ 1 - 1
netbox/templates/wireless/wirelesslan.html

@@ -30,7 +30,7 @@
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
-        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
+        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %}
         {% plugin_left_page object %}
         {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">

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

@@ -0,0 +1,48 @@
+{% 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">SSID</th>
+              <td>{{ object.ssid|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% 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 'inc/custom_fields_panel.html' %}
+      {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 10 - 1
netbox/wireless/api/nested_serializers.py

@@ -5,12 +5,21 @@ from wireless.models import *
 
 
 __all__ = (
 __all__ = (
     'NestedWirelessLANSerializer',
     'NestedWirelessLANSerializer',
+    'NestedWirelessLinkSerializer',
 )
 )
 
 
 
 
 class NestedWirelessLANSerializer(WritableNestedSerializer):
 class NestedWirelessLANSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
 
 
     class Meta:
     class Meta:
         model = WirelessLAN
         model = WirelessLAN
         fields = ['id', 'url', 'display', 'ssid']
         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']

+ 16 - 1
netbox/wireless/api/serializers.py

@@ -1,16 +1,19 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from dcim.api.serializers import NestedInterfaceSerializer
 from ipam.api.serializers import NestedVLANSerializer
 from ipam.api.serializers import NestedVLANSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from wireless.models import *
 from wireless.models import *
+from .nested_serializers import *
 
 
 __all__ = (
 __all__ = (
     'WirelessLANSerializer',
     'WirelessLANSerializer',
+    'WirelessLinkSerializer',
 )
 )
 
 
 
 
 class WirelessLANSerializer(PrimaryModelSerializer):
 class WirelessLANSerializer(PrimaryModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
     vlan = NestedVLANSerializer(required=False, allow_null=True)
     vlan = NestedVLANSerializer(required=False, allow_null=True)
 
 
     class Meta:
     class Meta:
@@ -18,3 +21,15 @@ class WirelessLANSerializer(PrimaryModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'ssid', 'description', 'vlan',
             'id', 'url', 'display', 'ssid', 'description', 'vlan',
         ]
         ]
+
+
+class WirelessLinkSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail')
+    interface_a = NestedInterfaceSerializer()
+    interface_b = NestedInterfaceSerializer()
+
+    class Meta:
+        model = WirelessLAN
+        fields = [
+            'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description',
+        ]

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

@@ -6,6 +6,7 @@ router = OrderedDefaultRouter()
 router.APIRootView = views.WirelessRootView
 router.APIRootView = views.WirelessRootView
 
 
 router.register('wireless-lans', views.WirelessLANViewSet)
 router.register('wireless-lans', views.WirelessLANViewSet)
+router.register('wireless-links', views.WirelessLinkViewSet)
 
 
 app_name = 'wireless-api'
 app_name = 'wireless-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 7 - 5
netbox/wireless/api/views.py

@@ -14,11 +14,13 @@ class WirelessRootView(APIRootView):
         return 'Wireless'
         return 'Wireless'
 
 
 
 
-#
-# Providers
-#
-
 class WirelessLANViewSet(CustomFieldModelViewSet):
 class WirelessLANViewSet(CustomFieldModelViewSet):
-    queryset = WirelessLAN.objects.prefetch_related('tags')
+    queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags')
     serializer_class = serializers.WirelessLANSerializer
     serializer_class = serializers.WirelessLANSerializer
     filterset_class = filtersets.WirelessLANFilterSet
     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

+ 22 - 0
netbox/wireless/filtersets.py

@@ -7,6 +7,7 @@ from .models import *
 
 
 __all__ = (
 __all__ = (
     'WirelessLANFilterSet',
     'WirelessLANFilterSet',
+    'WirelessLinkFilterSet',
 )
 )
 
 
 
 
@@ -29,3 +30,24 @@ class WirelessLANFilterSet(PrimaryModelFilterSet):
             Q(description__icontains=value)
             Q(description__icontains=value)
         )
         )
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
+
+
+class WirelessLinkFilterSet(PrimaryModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = WirelessLink
+        fields = ['id', 'ssid']
+
+    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)

+ 24 - 3
netbox/wireless/forms/bulk_edit.py

@@ -4,9 +4,11 @@ from dcim.models import *
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField
+from wireless.constants import SSID_MAX_LENGTH
 
 
 __all__ = (
 __all__ = (
     'WirelessLANBulkEditForm',
     'WirelessLANBulkEditForm',
+    'WirelessLinkBulkEditForm',
 )
 )
 
 
 
 
@@ -19,11 +21,30 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
     )
     )
+    ssid = forms.CharField(
+        max_length=SSID_MAX_LENGTH,
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['vlan', 'ssid', 'description']
+
+
+class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerFeed.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    ssid = forms.CharField(
+        max_length=SSID_MAX_LENGTH,
+        required=False
+    )
     description = forms.CharField(
     description = forms.CharField(
         required=False
         required=False
     )
     )
 
 
     class Meta:
     class Meta:
-        nullable_fields = [
-            'vlan', 'description',
-        ]
+        nullable_fields = ['ssid', 'description']

+ 16 - 1
netbox/wireless/forms/bulk_import.py

@@ -1,10 +1,12 @@
+from dcim.models import Interface
 from extras.forms import CustomFieldModelCSVForm
 from extras.forms import CustomFieldModelCSVForm
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.forms import CSVModelChoiceField
 from utilities.forms import CSVModelChoiceField
-from wireless.models import WirelessLAN
+from wireless.models import *
 
 
 __all__ = (
 __all__ = (
     'WirelessLANCSVForm',
     'WirelessLANCSVForm',
+    'WirelessLinkCSVForm',
 )
 )
 
 
 
 
@@ -18,3 +20,16 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         model = WirelessLAN
         model = WirelessLAN
         fields = ('ssid', 'description', 'vlan')
         fields = ('ssid', 'description', 'vlan')
+
+
+class WirelessLinkCSVForm(CustomFieldModelCSVForm):
+    interface_a = CSVModelChoiceField(
+        queryset=Interface.objects.all()
+    )
+    interface_b = CSVModelChoiceField(
+        queryset=Interface.objects.all()
+    )
+
+    class Meta:
+        model = WirelessLink
+        fields = ('interface_a', 'interface_b', 'ssid', 'description')

+ 27 - 1
netbox/wireless/forms/filtersets.py

@@ -3,7 +3,12 @@ from django.utils.translation import gettext as _
 
 
 from extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
 from utilities.forms import BootstrapMixin, TagFilterField
 from utilities.forms import BootstrapMixin, TagFilterField
-from .models import WirelessLAN
+from wireless.models import *
+
+__all__ = (
+    'WirelessLANFilterForm',
+    'WirelessLinkFilterForm',
+)
 
 
 
 
 class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
 class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
@@ -16,4 +21,25 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
         label=_('Search')
         label=_('Search')
     )
     )
+    ssid = forms.CharField(
+        required=False,
+        label='SSID'
+    )
+    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'
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)

+ 28 - 1
netbox/wireless/forms/models.py

@@ -1,11 +1,13 @@
+from dcim.models import Interface
 from extras.forms import CustomFieldModelForm
 from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField
-from wireless.models import WirelessLAN
+from wireless.models import *
 
 
 __all__ = (
 __all__ = (
     'WirelessLANForm',
     'WirelessLANForm',
+    'WirelessLinkForm',
 )
 )
 
 
 
 
@@ -28,3 +30,28 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm):
             ('Wireless LAN', ('ssid', 'description', 'tags')),
             ('Wireless LAN', ('ssid', 'description', 'tags')),
             ('VLAN', ('vlan',)),
             ('VLAN', ('vlan',)),
         )
         )
+
+
+class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm):
+    interface_a = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        query_params={
+            'kind': 'wireless'
+        }
+    )
+    interface_b = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        query_params={
+            'kind': 'wireless'
+        }
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = WirelessLink
+        fields = [
+            'interface_a', 'interface_b', 'ssid', 'description', 'tags',
+        ]

+ 0 - 34
netbox/wireless/migrations/0001_initial.py

@@ -1,34 +0,0 @@
-import django.core.serializers.json
-from django.db import migrations, models
-import django.db.models.deletion
-import taggit.managers
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        ('ipam', '0050_iprange'),
-        ('extras', '0062_clear_secrets_changelog'),
-    ]
-
-    operations = [
-        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)),
-                ('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'),
-            },
-        ),
-    ]

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

@@ -0,0 +1,57 @@
+# Generated by Django 3.2.8 on 2021-10-13 13:44
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('dcim', '0136_wireless'),
+        ('extras', '0062_clear_secrets_changelog'),
+        ('ipam', '0050_iprange'),
+    ]
+
+    operations = [
+        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)),
+                ('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)),
+                ('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')},
+            },
+        ),
+    ]

+ 80 - 3
netbox/wireless/models.py

@@ -6,19 +6,22 @@ from dcim.constants import WIRELESS_IFACE_TYPES
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import BigIDModel, PrimaryModel
 from netbox.models import BigIDModel, PrimaryModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
+from .constants import SSID_MAX_LENGTH
 
 
 __all__ = (
 __all__ = (
     'WirelessLAN',
     'WirelessLAN',
+    'WirelessLink',
 )
 )
 
 
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class WirelessLAN(PrimaryModel):
 class WirelessLAN(PrimaryModel):
     """
     """
-    A service set identifier belonging to a wireless network.
+    A wireless network formed among an arbitrary number of access point and clients.
     """
     """
     ssid = models.CharField(
     ssid = models.CharField(
-        max_length=32
+        max_length=SSID_MAX_LENGTH,
+        verbose_name='SSID'
     )
     )
     vlan = models.ForeignKey(
     vlan = models.ForeignKey(
         to='ipam.VLAN',
         to='ipam.VLAN',
@@ -42,4 +45,78 @@ class WirelessLAN(PrimaryModel):
         return self.ssid
         return self.ssid
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return reverse('wireless:ssid', args=[self.pk])
+        return reverse('wireless:wirelesslan', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class WirelessLink(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'
+    )
+    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()
+
+    class Meta:
+        ordering = ['pk']
+        unique_together = ('interface_a', 'interface_b')
+
+    def get_absolute_url(self):
+        return reverse('wireless:wirelesslink', args=[self.pk])
+
+    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)

+ 24 - 1
netbox/wireless/tables.py

@@ -1,10 +1,11 @@
 import django_tables2 as tables
 import django_tables2 as tables
 
 
-from .models import WirelessLAN
+from .models import *
 from utilities.tables import BaseTable, TagColumn, ToggleColumn
 from utilities.tables import BaseTable, TagColumn, ToggleColumn
 
 
 __all__ = (
 __all__ = (
     'WirelessLANTable',
     'WirelessLANTable',
+    'WirelessLinkTable',
 )
 )
 
 
 
 
@@ -21,3 +22,25 @@ class WirelessLANTable(BaseTable):
         model = WirelessLAN
         model = WirelessLAN
         fields = ('pk', 'ssid', 'description', 'vlan')
         fields = ('pk', 'ssid', 'description', 'vlan')
         default_columns = ('pk', 'ssid', 'description', 'vlan')
         default_columns = ('pk', 'ssid', 'description', 'vlan')
+
+
+class WirelessLinkTable(BaseTable):
+    pk = ToggleColumn()
+    id = tables.Column(
+        linkify=True,
+        verbose_name='ID'
+    )
+    interface_a = tables.Column(
+        linkify=True
+    )
+    interface_b = tables.Column(
+        linkify=True
+    )
+    tags = TagColumn(
+        url_name='wireless:wirelesslink_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = WirelessLink
+        fields = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description')
+        default_columns = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description')

+ 12 - 0
netbox/wireless/urls.py

@@ -19,4 +19,16 @@ urlpatterns = (
     path('wireless-lans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}),
     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}),
     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}),
+
 )
 )

+ 43 - 0
netbox/wireless/views.py

@@ -44,3 +44,46 @@ class WirelessLANBulkDeleteView(generic.BulkDeleteView):
     queryset = WirelessLAN.objects.all()
     queryset = WirelessLAN.objects.all()
     filterset = filtersets.WirelessLANFilterSet
     filterset = filtersets.WirelessLANFilterSet
     table = tables.WirelessLANTable
     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