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 %} +
+
+
+
SSID
+
+ + + + + + + + + + + + + +
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