diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index 74000e978..7ad64aeae 100644
--- a/netbox/netbox/api/views.py
+++ b/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)),
)))
diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py
index bb752b8c4..812c1656d 100644
--- a/netbox/netbox/graphql/schema.py
+++ b/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
diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py
index a3978f16e..0a78f35ab 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -188,6 +188,19 @@ CONNECTIONS_MENU = Menu(
),
)
+WIRELESS_MENU = Menu(
+ label='Wireless',
+ icon_class='mdi mdi-wifi',
+ groups=(
+ MenuGroup(
+ label='Wireless',
+ items=(
+ get_model_item('wireless', 'ssid', 'SSIDs'),
+ ),
+ ),
+ ),
+)
+
IPAM_MENU = Menu(
label='IPAM',
icon_class='mdi mdi-counter',
@@ -343,6 +356,7 @@ MENUS = [
ORGANIZATION_MENU,
DEVICES_MENU,
CONNECTIONS_MENU,
+ WIRELESS_MENU,
IPAM_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 3df9a855a..e41c77d1d 100644
--- a/netbox/netbox/settings.py
+++ b/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',
]
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index 3d4c60c93..4e0a2e2c6 100644
--- a/netbox/netbox/urls.py
+++ b/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'),
diff --git a/netbox/templates/wireless/ssid.html b/netbox/templates/wireless/ssid.html
new file mode 100644
index 000000000..5425149aa
--- /dev/null
+++ b/netbox/templates/wireless/ssid.html
@@ -0,0 +1,46 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Name |
+ {{ object.name }} |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+ VLAN |
+
+ {% if object.vlan %}
+ {{ object.vlan }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+
+
+
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/wireless/__init__.py b/netbox/wireless/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/wireless/api/__init__.py b/netbox/wireless/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py
new file mode 100644
index 000000000..50454a641
--- /dev/null
+++ b/netbox/wireless/api/nested_serializers.py
@@ -0,0 +1,16 @@
+from rest_framework import serializers
+
+from netbox.api import WritableNestedSerializer
+from wireless.models import *
+
+__all__ = (
+ 'NestedSSIDSerializer',
+)
+
+
+class NestedSSIDSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail')
+
+ class Meta:
+ model = SSID
+ fields = ['id', 'url', 'display', 'name']
diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py
new file mode 100644
index 000000000..c129e5c96
--- /dev/null
+++ b/netbox/wireless/api/serializers.py
@@ -0,0 +1,21 @@
+from rest_framework import serializers
+
+from dcim.api.serializers import NestedInterfaceSerializer
+from ipam.api.serializers import NestedVLANSerializer
+from netbox.api.serializers import PrimaryModelSerializer
+from wireless.models import *
+
+__all__ = (
+ 'SSIDSerializer',
+)
+
+
+class SSIDSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail')
+ vlan = NestedVLANSerializer(required=False, allow_null=True)
+
+ class Meta:
+ model = SSID
+ fields = [
+ 'id', 'url', 'display', 'name', 'description', 'vlan',
+ ]
diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py
new file mode 100644
index 000000000..f6936708c
--- /dev/null
+++ b/netbox/wireless/api/urls.py
@@ -0,0 +1,12 @@
+from netbox.api import OrderedDefaultRouter
+from . import views
+
+
+router = OrderedDefaultRouter()
+router.APIRootView = views.WirelessRootView
+
+# SSIDs
+router.register('ssids', views.SSIDViewSet)
+
+app_name = 'wireless-api'
+urlpatterns = router.urls
diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py
new file mode 100644
index 000000000..97827eb7e
--- /dev/null
+++ b/netbox/wireless/api/views.py
@@ -0,0 +1,24 @@
+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'
+
+
+#
+# Providers
+#
+
+class SSIDViewSet(CustomFieldModelViewSet):
+ queryset = SSID.objects.prefetch_related('tags')
+ serializer_class = serializers.SSIDSerializer
+ filterset_class = filtersets.SSIDFilterSet
diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py
new file mode 100644
index 000000000..1f6deff22
--- /dev/null
+++ b/netbox/wireless/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class WirelessConfig(AppConfig):
+ name = 'wireless'
diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py
new file mode 100644
index 000000000..232bc74ff
--- /dev/null
+++ b/netbox/wireless/filtersets.py
@@ -0,0 +1,31 @@
+import django_filters
+from django.db.models import Q
+
+from extras.filters import TagFilter
+from netbox.filtersets import PrimaryModelFilterSet
+from .models import *
+
+__all__ = (
+ 'SSIDFilterSet',
+)
+
+
+class SSIDFilterSet(PrimaryModelFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
+ tag = TagFilter()
+
+ class Meta:
+ model = SSID
+ fields = ['id', 'name']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ qs_filter = (
+ Q(name__icontains=value) |
+ Q(description__icontains=value)
+ )
+ return queryset.filter(qs_filter)
diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py
new file mode 100644
index 000000000..62c2ec2d9
--- /dev/null
+++ b/netbox/wireless/forms/__init__.py
@@ -0,0 +1,4 @@
+from .models import *
+from .filtersets import *
+from .bulk_edit import *
+from .bulk_import import *
diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py
new file mode 100644
index 000000000..ed9fb650b
--- /dev/null
+++ b/netbox/wireless/forms/bulk_edit.py
@@ -0,0 +1,29 @@
+from django import forms
+
+from dcim.models import *
+from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
+from ipam.models import VLAN
+from utilities.forms import BootstrapMixin, DynamicModelChoiceField
+
+__all__ = (
+ 'SSIDBulkEditForm',
+)
+
+
+class SSIDBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=PowerFeed.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ vlan = DynamicModelChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False,
+ )
+ description = forms.CharField(
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = [
+ 'vlan', 'description',
+ ]
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
new file mode 100644
index 000000000..0cf997fd3
--- /dev/null
+++ b/netbox/wireless/forms/bulk_import.py
@@ -0,0 +1,20 @@
+from extras.forms import CustomFieldModelCSVForm
+from ipam.models import VLAN
+from utilities.forms import CSVModelChoiceField
+from wireless.models import SSID
+
+__all__ = (
+ 'SSIDCSVForm',
+)
+
+
+class SSIDCSVForm(CustomFieldModelCSVForm):
+ vlan = CSVModelChoiceField(
+ queryset=VLAN.objects.all(),
+ to_field_name='name',
+ help_text='Bridged VLAN'
+ )
+
+ class Meta:
+ model = SSID
+ fields = ('name', 'description', 'vlan')
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
new file mode 100644
index 000000000..733b807f7
--- /dev/null
+++ b/netbox/wireless/forms/filtersets.py
@@ -0,0 +1,19 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from dcim.models import *
+from extras.forms import CustomFieldModelFilterForm
+from utilities.forms import BootstrapMixin, TagFilterField
+
+
+class SSIDFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = PowerFeed
+ field_groups = [
+ ['q', 'tag'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py
new file mode 100644
index 000000000..ea6d51223
--- /dev/null
+++ b/netbox/wireless/forms/models.py
@@ -0,0 +1,32 @@
+from dcim.constants import *
+from dcim.models import *
+from extras.forms import CustomFieldModelForm
+from extras.models import Tag
+from ipam.models import VLAN
+from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from wireless.models import SSID
+
+__all__ = (
+ 'SSIDForm',
+)
+
+
+class SSIDForm(BootstrapMixin, CustomFieldModelForm):
+ vlan = DynamicModelChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = SSID
+ fields = [
+ 'name', 'description', 'vlan', 'tags',
+ ]
+ fieldsets = (
+ ('SSID', ('name', 'description', 'tags')),
+ ('VLAN', ('vlan',)),
+ )
diff --git a/netbox/wireless/graphql/__init__.py b/netbox/wireless/graphql/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py
new file mode 100644
index 000000000..d0beec7d9
--- /dev/null
+++ b/netbox/wireless/graphql/schema.py
@@ -0,0 +1,9 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class WirelessQuery(graphene.ObjectType):
+ ssid = ObjectField(SSIDType)
+ ssid_list = ObjectListField(SSIDType)
diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py
new file mode 100644
index 000000000..66e73429d
--- /dev/null
+++ b/netbox/wireless/graphql/types.py
@@ -0,0 +1,14 @@
+from wireless import filtersets, models
+from netbox.graphql.types import ObjectType
+
+__all__ = (
+ 'SSIDType',
+)
+
+
+class SSIDType(ObjectType):
+
+ class Meta:
+ model = models.SSID
+ fields = '__all__'
+ filterset_class = filtersets.SSIDFilterSet
diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py
new file mode 100644
index 000000000..b0011dad9
--- /dev/null
+++ b/netbox/wireless/migrations/0001_initial.py
@@ -0,0 +1,36 @@
+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='SSID',
+ 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=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': 'SSID',
+ 'verbose_name_plural': 'SSIDs',
+ 'ordering': ('name', 'pk'),
+ },
+ ),
+ ]
diff --git a/netbox/wireless/migrations/__init__.py b/netbox/wireless/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
new file mode 100644
index 000000000..5bb964345
--- /dev/null
+++ b/netbox/wireless/models.py
@@ -0,0 +1,40 @@
+from django.db import models
+
+from extras.utils import extras_features
+from netbox.models import PrimaryModel
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+ 'SSID',
+)
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class SSID(PrimaryModel):
+ """
+ A service set identifier belonging to a wireless network.
+ """
+ name = models.CharField(
+ max_length=32
+ )
+ 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 = ('name', 'pk')
+ verbose_name = 'SSID'
+ verbose_name_plural = 'SSIDs'
+
+ def __str__(self):
+ return self.name
diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py
new file mode 100644
index 000000000..846296bb4
--- /dev/null
+++ b/netbox/wireless/tables.py
@@ -0,0 +1,24 @@
+import django_tables2 as tables
+
+from .models import SSID
+from utilities.tables import BaseTable, TagColumn, ToggleColumn
+
+__all__ = (
+ 'SSIDTable',
+)
+
+
+class SSIDTable(BaseTable):
+ pk = ToggleColumn()
+ id = tables.Column(
+ linkify=True,
+ verbose_name='ID'
+ )
+ tags = TagColumn(
+ url_name='dcim:cable_list'
+ )
+
+ class Meta(BaseTable.Meta):
+ model = SSID
+ fields = ('pk', 'id', 'name', 'description', 'vlan')
+ default_columns = ('pk', 'name', 'description', 'vlan')
diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py
new file mode 100644
index 000000000..57e0eab9b
--- /dev/null
+++ b/netbox/wireless/urls.py
@@ -0,0 +1,22 @@
+from django.urls import path
+
+from extras.views import ObjectChangeLogView, ObjectJournalView
+from . import views
+from .models import *
+
+app_name = 'wireless'
+urlpatterns = (
+
+ # SSIDs
+ path('ssids/', views.SSIDListView.as_view(), name='ssid_list'),
+ path('ssids/add/', views.SSIDEditView.as_view(), name='ssid_add'),
+ path('ssids/import/', views.SSIDBulkImportView.as_view(), name='ssid_import'),
+ path('ssids/edit/', views.SSIDBulkEditView.as_view(), name='ssid_bulk_edit'),
+ path('ssids/delete/', views.SSIDBulkDeleteView.as_view(), name='ssid_bulk_delete'),
+ path('ssids//', views.SSIDView.as_view(), name='ssid'),
+ path('ssids//edit/', views.SSIDEditView.as_view(), name='ssid_edit'),
+ path('ssids//delete/', views.SSIDDeleteView.as_view(), name='ssid_delete'),
+ path('ssids//changelog/', ObjectChangeLogView.as_view(), name='ssid_changelog', kwargs={'model': SSID}),
+ path('ssids//journal/', ObjectJournalView.as_view(), name='ssid_journal', kwargs={'model': SSID}),
+
+)
diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py
new file mode 100644
index 000000000..b0d1f5156
--- /dev/null
+++ b/netbox/wireless/views.py
@@ -0,0 +1,46 @@
+from netbox.views import generic
+from . import filtersets, forms, tables
+from .models import *
+
+
+#
+# SSIDs
+#
+
+class SSIDListView(generic.ObjectListView):
+ queryset = SSID.objects.all()
+ filterset = filtersets.SSIDFilterSet
+ filterset_form = forms.SSIDFilterForm
+ table = tables.SSIDTable
+
+
+class SSIDView(generic.ObjectView):
+ queryset = SSID.objects.prefetch_related('power_panel', 'rack')
+
+
+class SSIDEditView(generic.ObjectEditView):
+ queryset = SSID.objects.all()
+ model_form = forms.SSIDForm
+
+
+class SSIDDeleteView(generic.ObjectDeleteView):
+ queryset = SSID.objects.all()
+
+
+class SSIDBulkImportView(generic.BulkImportView):
+ queryset = SSID.objects.all()
+ model_form = forms.SSIDCSVForm
+ table = tables.SSIDTable
+
+
+class SSIDBulkEditView(generic.BulkEditView):
+ queryset = SSID.objects.prefetch_related('power_panel', 'rack')
+ filterset = filtersets.SSIDFilterSet
+ table = tables.SSIDTable
+ form = forms.SSIDBulkEditForm
+
+
+class SSIDBulkDeleteView(generic.BulkDeleteView):
+ queryset = SSID.objects.prefetch_related('power_panel', 'rack')
+ filterset = filtersets.SSIDFilterSet
+ table = tables.SSIDTable