{% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all %}
- {% plugin_right_page object %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html
index b4d097513..824f2bf24 100644
--- a/netbox/templates/virtualization/vminterface_edit.html
+++ b/netbox/templates/virtualization/vminterface_edit.html
@@ -17,6 +17,7 @@
{% render_field form.name %}
{% render_field form.enabled %}
{% render_field form.parent %}
+ {% render_field form.bridge %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% render_field form.description %}
diff --git a/netbox/templates/wireless/inc/authentication_attrs.html b/netbox/templates/wireless/inc/authentication_attrs.html
new file mode 100644
index 000000000..ed4c7546c
--- /dev/null
+++ b/netbox/templates/wireless/inc/authentication_attrs.html
@@ -0,0 +1,21 @@
+{% load helpers %}
+
+
+
+
+
+
+ Type |
+ {{ object.get_auth_type_display|placeholder }} |
+
+
+ Cipher |
+ {{ object.get_auth_cipher_display|placeholder }} |
+
+
+ PSK |
+ {{ object.auth_psk|placeholder }} |
+
+
+
+
diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html
new file mode 100644
index 000000000..e33047539
--- /dev/null
+++ b/netbox/templates/wireless/inc/wirelesslink_interface.html
@@ -0,0 +1,54 @@
+{% load helpers %}
+
+
+
+ Device |
+
+ {{ interface.device }}
+ |
+
+
+ Interface |
+
+ {{ interface }}
+ |
+
+
+ Type |
+
+ {{ interface.get_type_display }}
+ |
+
+
+ Role |
+
+ {{ interface.get_rf_role_display|placeholder }}
+ |
+
+
+ Channel |
+
+ {{ interface.get_rf_channel_display|placeholder }}
+ |
+
+
+ Channel Frequency |
+
+ {% if interface.rf_channel_frequency %}
+ {{ interface.rf_channel_frequency|simplify_decimal }} MHz
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+ Channel Width |
+
+ {% if interface.rf_channel_width %}
+ {{ interface.rf_channel_width|simplify_decimal }} MHz
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html
new file mode 100644
index 000000000..370102ed1
--- /dev/null
+++ b/netbox/templates/wireless/wirelesslan.html
@@ -0,0 +1,64 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+
+
+
+
+
+
+
+ SSID |
+ {{ object.ssid }} |
+
+
+ Group |
+
+ {% if object.group %}
+ {{ object.group }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+ VLAN |
+
+ {% if object.vlan %}
+ {{ object.vlan }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'wireless/inc/authentication_attrs.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=interfaces_table %}
+
+
+ {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html
new file mode 100644
index 000000000..3e6bc382e
--- /dev/null
+++ b/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 %}
+
{{ group }}
+ {% endfor %}
+{% endblock %}
+
+{% block content %}
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=wirelesslans_table %}
+
+ {% if perms.wireless.add_wirelesslan %}
+
+ {% endif %}
+
+ {% include 'inc/paginator.html' with paginator=wirelesslans_table.paginator page=wirelesslans_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html
new file mode 100644
index 000000000..6ad88729d
--- /dev/null
+++ b/netbox/templates/wireless/wirelesslink.html
@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+
+
+
+
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %}
+
+
+
+
+
+
+
+ Status |
+
+ {{ object.get_status_display }}
+ |
+
+
+ SSID |
+ {{ object.ssid|placeholder }} |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+
+
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
+
+
+ {% include 'wireless/inc/authentication_attrs.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html
new file mode 100644
index 000000000..034d147de
--- /dev/null
+++ b/netbox/templates/wireless/wirelesslink_edit.html
@@ -0,0 +1,33 @@
+{% extends 'generic/object_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+
+
+
+
+
Side A
+
+ {% render_field form.device_a %}
+ {% render_field form.interface_a %}
+
+
+
+
+
+
Side B
+
+ {% render_field form.device_b %}
+ {% render_field form.interface_b %}
+
+
+
+ {% if form.custom_fields %}
+
+
+
Custom Fields
+
+ {% render_custom_fields form %}
+
+ {% endif %}
+{% endblock %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 27a14b350..90c13725c 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType
from rest_framework import serializers
from netbox.api import ChoiceField, ContentTypeField
-from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
from tenancy.choices import ContactPriorityChoices
from tenancy.models import *
from .nested_serializers import *
@@ -20,8 +20,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
class Meta:
model = TenantGroup
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
- 'tenant_count', '_depth',
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'tenant_count', '_depth',
]
@@ -60,18 +60,18 @@ class ContactGroupSerializer(NestedGroupModelSerializer):
class Meta:
model = ContactGroup
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
- 'contact_count', '_depth',
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'contact_count', '_depth',
]
-class ContactRoleSerializer(OrganizationalModelSerializer):
+class ContactRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
class Meta:
model = ContactRole
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 7ce16c143..8c7c33aba 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -30,7 +30,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
'group',
'tenant_count',
cumulative=True
- )
+ ).prefetch_related('tags')
serializer_class = serializers.TenantGroupSerializer
filterset_class = filtersets.TenantGroupFilterSet
@@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet):
'group',
'contact_count',
cumulative=True
- )
+ ).prefetch_related('tags')
serializer_class = serializers.ContactGroupSerializer
filterset_class = filtersets.ContactGroupFilterSet
class ContactRoleViewSet(CustomFieldModelViewSet):
- queryset = ContactRole.objects.all()
+ queryset = ContactRole.objects.prefetch_related('tags')
serializer_class = serializers.ContactRoleSerializer
filterset_class = filtersets.ContactRoleFilterSet
class ContactViewSet(CustomFieldModelViewSet):
- queryset = Contact.objects.prefetch_related(
- 'group', 'tags'
- )
+ queryset = Contact.objects.prefetch_related('group', 'tags')
serializer_class = serializers.ContactSerializer
filterset_class = filtersets.ContactFilterSet
class ContactAssignmentViewSet(CustomFieldModelViewSet):
- queryset = ContactAssignment.objects.prefetch_related(
- 'contact', 'role'
- )
+ queryset = ContactAssignment.objects.prefetch_related('contact', 'role')
serializer_class = serializers.ContactAssignmentSerializer
filterset_class = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index f6d0ac72e..dd73edace 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -33,6 +33,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Tenant group (slug)',
)
+ tag = TagFilter()
class Meta:
model = TenantGroup
@@ -118,6 +119,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Contact group (slug)',
)
+ tag = TagFilter()
class Meta:
model = ContactGroup
@@ -125,6 +127,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
class ContactRoleFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = ContactRole
diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py
index a34b8def1..f461fe73c 100644
--- a/netbox/tenancy/forms/bulk_edit.py
+++ b/netbox/tenancy/forms/bulk_edit.py
@@ -17,7 +17,7 @@ __all__ = (
# Tenants
#
-class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class TenantGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -55,7 +55,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
# Contacts
#
-class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ContactGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['parent', 'description']
-class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ContactRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ContactRole.objects.all(),
widget=forms.MultipleHiddenInput
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 69941701f..b693db68f 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -31,6 +31,7 @@ class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Parent group'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
@@ -71,18 +72,17 @@ class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Parent group'),
fetch_trigger='open'
)
+ tag = TagFilterField(model)
class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ContactRole
- field_groups = [
- ['q'],
- ]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
+ tag = TagFilterField(model)
class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py
index b15065705..0237e4ef8 100644
--- a/netbox/tenancy/forms/models.py
+++ b/netbox/tenancy/forms/models.py
@@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = TenantGroup
fields = [
- 'parent', 'name', 'slug', 'description',
+ 'parent', 'name', 'slug', 'description', 'tags',
]
@@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = ContactGroup
- fields = ['parent', 'name', 'slug', 'description']
+ fields = ('parent', 'name', 'slug', 'description', 'tags')
class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = ContactRole
- fields = ['name', 'slug', 'description']
+ fields = ('name', 'slug', 'description', 'tags')
class ContactForm(BootstrapMixin, CustomFieldModelForm):
diff --git a/netbox/tenancy/migrations/0004_extend_tag_support.py b/netbox/tenancy/migrations/0004_extend_tag_support.py
new file mode 100644
index 000000000..942be38b5
--- /dev/null
+++ b/netbox/tenancy/migrations/0004_extend_tag_support.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('tenancy', '0003_contacts'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contactgroup',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='contactrole',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='tenantgroup',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/tenancy/models/__init__.py b/netbox/tenancy/models/__init__.py
new file mode 100644
index 000000000..6d62edd20
--- /dev/null
+++ b/netbox/tenancy/models/__init__.py
@@ -0,0 +1,2 @@
+from .contacts import *
+from .tenants import *
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models/contacts.py
similarity index 66%
rename from netbox/tenancy/models.py
rename to netbox/tenancy/models/contacts.py
index c709236e2..2669aa121 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models/contacts.py
@@ -1,117 +1,23 @@
-from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
-from mptt.models import MPTTModel, TreeForeignKey
+from mptt.models import TreeForeignKey
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
+from tenancy.choices import *
from utilities.querysets import RestrictedQuerySet
-from .choices import *
-
__all__ = (
'ContactAssignment',
'Contact',
'ContactGroup',
'ContactRole',
- 'Tenant',
- 'TenantGroup',
)
-#
-# Tenants
-#
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class TenantGroup(NestedGroupModel):
- """
- An arbitrary collection of Tenants.
- """
- 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']
-
- def get_absolute_url(self):
- return reverse('tenancy:tenantgroup', args=[self.pk])
-
-
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Tenant(PrimaryModel):
- """
- A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
- department.
- """
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
- group = models.ForeignKey(
- to='tenancy.TenantGroup',
- on_delete=models.SET_NULL,
- related_name='tenants',
- blank=True,
- null=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
- comments = models.TextField(
- blank=True
- )
-
- # Generic relations
- contacts = GenericRelation(
- to='tenancy.ContactAssignment'
- )
-
- objects = RestrictedQuerySet.as_manager()
-
- clone_fields = [
- 'group', 'description',
- ]
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return reverse('tenancy:tenant', args=[self.pk])
-
-
-#
-# Contacts
-#
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class ContactGroup(NestedGroupModel):
"""
An arbitrary collection of Contacts.
@@ -145,7 +51,7 @@ class ContactGroup(NestedGroupModel):
return reverse('tenancy:contactgroup', args=[self.pk])
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ContactRole(OrganizationalModel):
"""
Functional role for a Contact assigned to an object.
diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py
new file mode 100644
index 000000000..7dae2c093
--- /dev/null
+++ b/netbox/tenancy/models/tenants.py
@@ -0,0 +1,96 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+from mptt.models import TreeForeignKey
+
+from extras.utils import extras_features
+from netbox.models import NestedGroupModel, PrimaryModel
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+ 'Tenant',
+ 'TenantGroup',
+)
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class TenantGroup(NestedGroupModel):
+ """
+ An arbitrary collection of Tenants.
+ """
+ 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']
+
+ def get_absolute_url(self):
+ return reverse('tenancy:tenantgroup', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Tenant(PrimaryModel):
+ """
+ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
+ department.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ max_length=100,
+ unique=True
+ )
+ group = models.ForeignKey(
+ to='tenancy.TenantGroup',
+ on_delete=models.SET_NULL,
+ related_name='tenants',
+ blank=True,
+ null=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ clone_fields = [
+ 'group', 'description',
+ ]
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('tenancy:tenant', args=[self.pk])
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index 5b254842b..02c431846 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -55,11 +55,14 @@ class TenantGroupTable(BaseTable):
url_params={'group_id': 'pk'},
verbose_name='Tenants'
)
+ tags = TagColumn(
+ url_name='tenancy:tenantgroup_list'
+ )
actions = ButtonsColumn(TenantGroup)
class Meta(BaseTable.Meta):
model = TenantGroup
- fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions')
+ fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
@@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable):
url_params={'role_id': 'pk'},
verbose_name='Contacts'
)
+ tags = TagColumn(
+ url_name='tenancy:contactgroup_list'
+ )
actions = ButtonsColumn(ContactGroup)
class Meta(BaseTable.Meta):
model = ContactGroup
- fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions')
+ fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py
index fb7ff3ce3..dcfcc1652 100644
--- a/netbox/tenancy/tests/test_views.py
+++ b/netbox/tenancy/tests/test_views.py
@@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for tenanantgroup in tenant_groups:
tenanantgroup.save()
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Tenant Group X',
'slug': 'tenant-group-x',
'description': 'A new tenant group',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
for tenanantgroup in contact_groups:
tenanantgroup.save()
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Contact Group X',
'slug': 'contact-group-x',
'description': 'A new contact group',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
ContactRole(name='Contact Role 3', slug='contact-role-3'),
])
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Devie Role X',
'slug': 'contact-role-x',
'description': 'New contact role',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
diff --git a/netbox/users/views.py b/netbox/users/views.py
index afee10eeb..ab17955e3 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -1,6 +1,5 @@
import logging
-from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -14,6 +13,7 @@ from django.utils.http import is_safe_url
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
+from netbox.config import get_config
from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm, TokenForm
from .models import Token
@@ -53,7 +53,7 @@ class LoginView(View):
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
- if settings.MAINTENANCE_MODE:
+ if get_config().MAINTENANCE_MODE:
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py
index e46af4b3e..4cc3ef601 100644
--- a/netbox/utilities/paginator.py
+++ b/netbox/utilities/paginator.py
@@ -1,8 +1,12 @@
-from django.conf import settings
from django.core.paginator import Paginator, Page
+from netbox.config import get_config
+
class EnhancedPaginator(Paginator):
+ default_page_lengths = (
+ 25, 50, 100, 250, 500, 1000
+ )
def __init__(self, object_list, per_page, orphans=None, **kwargs):
@@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator):
try:
per_page = int(per_page)
if per_page < 1:
- per_page = settings.PAGINATE_COUNT
+ per_page = get_config().PAGINATE_COUNT
except ValueError:
- per_page = settings.PAGINATE_COUNT
+ per_page = get_config().PAGINATE_COUNT
# Set orphans count based on page size
if orphans is None and per_page <= 50:
@@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator):
def _get_page(self, *args, **kwargs):
return EnhancedPage(*args, **kwargs)
+ def get_page_lengths(self):
+ if self.per_page not in self.default_page_lengths:
+ return sorted([*self.default_page_lengths, self.per_page])
+ return self.default_page_lengths
+
class EnhancedPage(Page):
@@ -57,17 +66,19 @@ def get_paginate_count(request):
Return the lesser of the calculated value and MAX_PAGE_SIZE.
"""
+ config = get_config()
+
if 'per_page' in request.GET:
try:
per_page = int(request.GET.get('per_page'))
if request.user.is_authenticated:
request.user.config.set('pagination.per_page', per_page, commit=True)
- return min(per_page, settings.MAX_PAGE_SIZE)
+ return min(per_page, config.MAX_PAGE_SIZE)
except ValueError:
pass
if request.user.is_authenticated:
- per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
- return min(per_page, settings.MAX_PAGE_SIZE)
+ per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
+ return min(per_page, config.MAX_PAGE_SIZE)
- return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)
+ return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE)
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 1695c8257..9b510d9ed 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -1,4 +1,5 @@
import datetime
+import decimal
import json
import re
from typing import Dict, Any
@@ -13,6 +14,7 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from markdown import markdown
+from netbox.config import get_config
from utilities.forms import get_selected_values, TableConfigForm
from utilities.utils import foreground_color
@@ -43,7 +45,7 @@ def render_markdown(value):
value = strip_tags(value)
# Sanitize Markdown links
- schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
+ schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
@@ -146,6 +148,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):
"""
diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py
index 5b711056a..1171bd496 100644
--- a/netbox/utilities/tests/test_api.py
+++ b/netbox/utilities/tests/test_api.py
@@ -1,6 +1,5 @@
import urllib.parse
-from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase, override_settings
from django.urls import reverse
@@ -10,6 +9,7 @@ from dcim.models import Region, Site
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from ipam.models import VLAN
+from netbox.config import get_config
from utilities.testing import APITestCase, disable_warnings
@@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase):
def test_default_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
- page_size = settings.PAGINATE_COUNT
+ page_size = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK)
diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py
index b087b0867..5fce17a3a 100644
--- a/netbox/utilities/validators.py
+++ b/netbox/utilities/validators.py
@@ -1,9 +1,10 @@
import re
-from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
+from netbox.config import get_config
+
class EnhancedURLValidator(URLValidator):
"""
@@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator):
r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE)
- schemes = settings.ALLOWED_URL_SCHEMES
+
+ def __init__(self, schemes=None, **kwargs):
+ super().__init__(**kwargs)
+ if schemes is not None:
+ self.schemes = get_config().ALLOWED_URL_SCHEMES
class ExclusionValidator(BaseValidator):
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 1928960a9..6cdc0e09a 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from netbox.api import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -17,26 +17,26 @@ from .nested_serializers import *
# Clusters
#
-class ClusterTypeSerializer(OrganizationalModelSerializer):
+class ClusterTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
cluster_count = serializers.IntegerField(read_only=True)
class Meta:
model = ClusterType
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'cluster_count',
]
-class ClusterGroupSerializer(OrganizationalModelSerializer):
+class ClusterGroupSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
cluster_count = serializers.IntegerField(read_only=True)
class Meta:
model = ClusterGroup
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'cluster_count',
]
@@ -107,6 +107,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
virtual_machine = NestedVirtualMachineSerializer()
parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
+ bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
@@ -120,8 +121,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
class Meta:
model = VMInterface
fields = [
- 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description',
- 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address',
+ 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
'count_ipaddresses',
]
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 8eebd2120..d07ace3d5 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView):
class ClusterTypeViewSet(CustomFieldModelViewSet):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
- )
+ ).prefetch_related('tags')
serializer_class = serializers.ClusterTypeSerializer
filterset_class = filtersets.ClusterTypeFilterSet
@@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet):
class ClusterGroupViewSet(CustomFieldModelViewSet):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
- )
+ ).prefetch_related('tags')
serializer_class = serializers.ClusterGroupSerializer
filterset_class = filtersets.ClusterGroupFilterSet
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 3fc1da8ea..dc084a67f 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -20,6 +20,7 @@ __all__ = (
class ClusterTypeFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = ClusterType
@@ -27,6 +28,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+ tag = TagFilter()
class Meta:
model = ClusterGroup
@@ -262,6 +264,11 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet):
queryset=VMInterface.objects.all(),
label='Parent interface (ID)',
)
+ bridge_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='bridge',
+ queryset=VMInterface.objects.all(),
+ label='Bridged interface (ID)',
+ )
mac_address = MultiValueMACAddressFilter(
label='MAC address',
)
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index c140fbc73..d6c190904 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -23,7 +23,7 @@ __all__ = (
)
-class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ClusterTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
widget=forms.MultipleHiddenInput
@@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description']
-class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ClusterGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -165,6 +165,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
queryset=VMInterface.objects.all(),
required=False
)
+ bridge = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False
+ )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
@@ -195,7 +199,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
class Meta:
nullable_fields = [
- 'parent', 'mtu', 'description',
+ 'parent', 'bridge', 'mtu', 'description',
]
def __init__(self, *args, **kwargs):
@@ -203,8 +207,9 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
if 'virtual_machine' in self.initial:
vm_id = self.initial.get('virtual_machine')
- # Restrict parent interface assignment by VM
+ # Restrict parent/bridge interface assignment by VM
self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
+ self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id)
# Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -231,6 +236,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
+ self.fields['parent'].choices = ()
+ self.fields['parent'].widget.attrs['disabled'] = True
+ self.fields['bridge'].choices = ()
+ self.fields['bridge'].widget.attrs['disabled'] = True
+
class VMInterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index d01418aa0..bd3279959 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -104,6 +104,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm):
queryset=VirtualMachine.objects.all(),
to_field_name='name'
)
+ parent = CSVModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Parent interface'
+ )
+ bridge = CSVModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Bridged interface'
+ )
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
@@ -113,7 +125,7 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm):
class Meta:
model = VMInterface
fields = (
- 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
)
def clean_enabled(self):
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 0bb5c2bd7..1e8156c33 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -22,26 +22,22 @@ __all__ = (
class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ClusterType
- field_groups = [
- ['q'],
- ]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
+ tag = TagFilterField(model)
class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ClusterGroup
- field_groups = [
- ['q'],
- ]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
+ tag = TagFilterField(model)
class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py
index d66bc9f1f..7fa5b0fa6 100644
--- a/netbox/virtualization/forms/models.py
+++ b/netbox/virtualization/forms/models.py
@@ -28,22 +28,30 @@ __all__ = (
class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = ClusterType
- fields = [
- 'name', 'slug', 'description',
- ]
+ fields = (
+ 'name', 'slug', 'description', 'tags',
+ )
class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = ClusterGroup
- fields = [
- 'name', 'slug', 'description',
- ]
+ fields = (
+ 'name', 'slug', 'description', 'tags',
+ )
class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
@@ -269,6 +277,11 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
required=False,
label='Parent interface'
)
+ bridge = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ label='Bridged interface'
+ )
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
@@ -298,8 +311,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
class Meta:
model = VMInterface
fields = [
- 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
- 'untagged_vlan', 'tagged_vlans',
+ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+ 'tags', 'untagged_vlan', 'tagged_vlans',
]
widgets = {
'virtual_machine': forms.HiddenInput(),
@@ -318,6 +331,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
# Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
+ self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id)
# Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py
index b58fb51f8..332334594 100644
--- a/netbox/virtualization/forms/object_create.py
+++ b/netbox/virtualization/forms/object_create.py
@@ -35,6 +35,13 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo
'virtual_machine_id': '$virtual_machine',
}
)
+ bridge = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ query_params={
+ 'virtual_machine_id': '$virtual_machine',
+ }
+ )
mac_address = forms.CharField(
required=False,
label='MAC Address'
@@ -61,7 +68,7 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo
required=False
)
field_order = (
- 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode',
+ 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags'
)
diff --git a/netbox/virtualization/migrations/0025_extend_tag_support.py b/netbox/virtualization/migrations/0025_extend_tag_support.py
new file mode 100644
index 000000000..c77aee194
--- /dev/null
+++ b/netbox/virtualization/migrations/0025_extend_tag_support.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('virtualization', '0024_cluster_relax_uniqueness'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clustergroup',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='clustertype',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0026_vminterface_bridge.py b/netbox/virtualization/migrations/0026_vminterface_bridge.py
new file mode 100644
index 000000000..04909c72c
--- /dev/null
+++ b/netbox/virtualization/migrations/0026_vminterface_bridge.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.8 on 2021-10-21 20:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0025_extend_tag_support'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vminterface',
+ name='bridge',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 11792944a..db2404546 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -1,4 +1,3 @@
-from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
+from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
@@ -30,7 +30,7 @@ __all__ = (
# Cluster types
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ClusterType(OrganizationalModel):
"""
A type of Cluster.
@@ -64,7 +64,7 @@ class ClusterType(OrganizationalModel):
# Cluster groups
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ClusterGroup(OrganizationalModel):
"""
An organizational group of Clusters.
@@ -340,7 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
@property
def primary_ip(self):
- if settings.PREFER_IPV4 and self.primary_ip4:
+ if get_config().PREFER_IPV4 and self.primary_ip4:
return self.primary_ip4
elif self.primary_ip6:
return self.primary_ip6
@@ -378,14 +378,6 @@ class VMInterface(PrimaryModel, BaseInterface):
max_length=200,
blank=True
)
- parent = models.ForeignKey(
- to='self',
- on_delete=models.SET_NULL,
- related_name='child_interfaces',
- null=True,
- blank=True,
- verbose_name='Parent interface'
- )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -423,6 +415,12 @@ class VMInterface(PrimaryModel, BaseInterface):
def clean(self):
super().clean()
+ # Parent validation
+
+ # An interface cannot be its own parent
+ if self.pk and self.parent_id == self.pk:
+ raise ValidationError({'parent': "An interface cannot be its own parent."})
+
# An interface's parent must belong to the same virtual machine
if self.parent and self.parent.virtual_machine != self.virtual_machine:
raise ValidationError({
@@ -430,15 +428,26 @@ class VMInterface(PrimaryModel, BaseInterface):
f"({self.parent.virtual_machine})."
})
- # An interface cannot be its own parent
- if self.pk and self.parent_id == self.pk:
- raise ValidationError({'parent': "An interface cannot be its own parent."})
+ # Bridge validation
+
+ # An interface cannot be bridged to itself
+ if self.pk and self.bridge_id == self.pk:
+ raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
+
+ # A bridged interface belong to the same virtual machine
+ if self.bridge and self.bridge.virtual_machine != self.virtual_machine:
+ raise ValidationError({
+ 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine "
+ f"({self.bridge.virtual_machine})."
+ })
+
+ # VLAN validation
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({
'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
- f"interface's parent virtual machine, or it must be global"
+ f"interface's parent virtual machine, or it must be global."
})
def to_objectchange(self, action):
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index b0e922e71..0a605267d 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -17,8 +17,6 @@ __all__ = (
'VMInterfaceTable',
)
-PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
-
VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
@@ -40,11 +38,14 @@ class ClusterTypeTable(BaseTable):
cluster_count = tables.Column(
verbose_name='Clusters'
)
+ tags = TagColumn(
+ url_name='virtualization:clustertype_list'
+ )
actions = ButtonsColumn(ClusterType)
class Meta(BaseTable.Meta):
model = ClusterType
- fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+ fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
@@ -60,11 +61,14 @@ class ClusterGroupTable(BaseTable):
cluster_count = tables.Column(
verbose_name='Clusters'
)
+ tags = TagColumn(
+ url_name='virtualization:clustergroup_list'
+ )
actions = ButtonsColumn(ClusterGroup)
class Meta(BaseTable.Meta):
model = ClusterGroup
- fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+ fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
@@ -130,7 +134,7 @@ class VirtualMachineTable(BaseTable):
)
primary_ip = tables.Column(
linkify=True,
- order_by=PRIMARY_IP_ORDERING,
+ order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address'
)
tags = TagColumn(
@@ -160,9 +164,6 @@ class VMInterfaceTable(BaseInterfaceTable):
name = tables.Column(
linkify=True
)
- parent = tables.Column(
- linkify=True
- )
tags = TagColumn(
url_name='virtualization:vminterface_list'
)
@@ -170,13 +171,19 @@ class VMInterfaceTable(BaseInterfaceTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = (
- 'pk', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+ 'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
- default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'parent', 'description')
+ default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
class VirtualMachineVMInterfaceTable(VMInterfaceTable):
+ parent = tables.Column(
+ linkify=True
+ )
+ bridge = tables.Column(
+ linkify=True
+ )
actions = ButtonsColumn(
model=VMInterface,
buttons=('edit', 'delete'),
@@ -186,8 +193,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = (
- 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses',
- 'untagged_vlan', 'tagged_vlans', 'actions',
+ 'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+ 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
)
default_columns = (
'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 3245fb9bf..4a9b67bf0 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -246,14 +246,15 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'virtual_machine': virtualmachine.pk,
'name': 'Interface 5',
'mode': InterfaceModeChoices.MODE_TAGGED,
+ 'bridge': interfaces[0].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},
{
'virtual_machine': virtualmachine.pk,
'name': 'Interface 6',
- 'parent': interfaces[0].pk,
'mode': InterfaceModeChoices.MODE_TAGGED,
+ 'parent': interfaces[1].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 0ca6364a5..a74ccc4d9 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -452,6 +452,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'parent_id': [parent_interface.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ def test_bridge(self):
+ # Create bridged interfaces
+ bridge_interface = VMInterface.objects.first()
+ bridged_interfaces = (
+ VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 1', bridge=bridge_interface),
+ VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 2', bridge=bridge_interface),
+ VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 3', bridge=bridge_interface),
+ )
+ VMInterface.objects.bulk_create(bridged_interfaces)
+
+ params = {'bridge_id': [bridge_interface.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
def test_mtu(self):
params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 020c9ebc5..7dc5660fd 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
])
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Cluster Group X',
'slug': 'cluster-group-x',
'description': 'A new cluster group',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
])
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Cluster Type X',
'slug': 'cluster-type-x',
'description': 'A new cluster type',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -242,10 +248,11 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
VirtualMachine.objects.bulk_create(virtualmachines)
- VMInterface.objects.bulk_create([
+ interfaces = VMInterface.objects.bulk_create([
VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'),
VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'),
VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'),
+ VMInterface(virtual_machine=virtualmachines[1], name='BRIDGE'),
])
vlans = (
@@ -262,6 +269,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'virtual_machine': virtualmachines[1].pk,
'name': 'Interface X',
'enabled': False,
+ 'bridge': interfaces[3].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 65000,
'description': 'New description',
@@ -275,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'virtual_machine': virtualmachines[1].pk,
'name_pattern': 'Interface [4-6]',
'enabled': False,
+ 'bridge': interfaces[3].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 2000,
'description': 'New description',
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..e9a840bfc
--- /dev/null
+++ b/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']
diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py
new file mode 100644
index 000000000..68e8181f1
--- /dev/null
+++ b/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',
+ ]
diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py
new file mode 100644
index 000000000..b02aa67c0
--- /dev/null
+++ b/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-groups', views.WirelessLANGroupViewSet)
+router.register('wireless-lans', views.WirelessLANViewSet)
+router.register('wireless-links', views.WirelessLinkViewSet)
+
+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..734f6940f
--- /dev/null
+++ b/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
diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py
new file mode 100644
index 000000000..59e47aba5
--- /dev/null
+++ b/netbox/wireless/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class WirelessConfig(AppConfig):
+ name = 'wireless'
+
+ def ready(self):
+ import wireless.signals
diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py
new file mode 100644
index 000000000..c8e7fd09f
--- /dev/null
+++ b/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'),
+ )
diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py
new file mode 100644
index 000000000..63de2b136
--- /dev/null
+++ b/netbox/wireless/constants.py
@@ -0,0 +1,2 @@
+SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007
+PSK_MAX_LENGTH = 64
diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py
new file mode 100644
index 000000000..654dd843f
--- /dev/null
+++ b/netbox/wireless/filtersets.py
@@ -0,0 +1,103 @@
+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'
+ )
+ tag = TagFilter()
+
+ 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)
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..4de1724f3
--- /dev/null
+++ b/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']
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
new file mode 100644
index 000000000..aa79e1fc7
--- /dev/null
+++ b/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')
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
new file mode 100644
index 000000000..b7eeec76b
--- /dev/null
+++ b/netbox/wireless/forms/filtersets.py
@@ -0,0 +1,102 @@
+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'
+ )
+ tag = TagFilterField(model)
+
+
+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
+ 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)
diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py
new file mode 100644
index 000000000..f7985a31d
--- /dev/null
+++ b/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',
+ }
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..cd8fd9f52
--- /dev/null
+++ b/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)
diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py
new file mode 100644
index 000000000..c3235e72e
--- /dev/null
+++ b/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
diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py
new file mode 100644
index 000000000..26f1e440b
--- /dev/null
+++ b/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')},
+ },
+ ),
+ ]
diff --git a/netbox/wireless/migrations/0002_wireless_auth.py b/netbox/wireless/migrations/0002_wireless_auth.py
new file mode 100644
index 000000000..9ca4e351c
--- /dev/null
+++ b/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),
+ ),
+ ]
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..45a7881b7
--- /dev/null
+++ b/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)
diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py
new file mode 100644
index 000000000..3b4831a8d
--- /dev/null
+++ b/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()
diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py
new file mode 100644
index 000000000..4f47ee7f9
--- /dev/null
+++ b/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',
+ )
diff --git a/netbox/wireless/tests/__init__.py b/netbox/wireless/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py
new file mode 100644
index 000000000..917b7b320
--- /dev/null
+++ b/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',
+ },
+ ]
diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py
new file mode 100644
index 000000000..50f89c4d6
--- /dev/null
+++ b/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)
diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py
new file mode 100644
index 000000000..4141af6d6
--- /dev/null
+++ b/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,
+ }
diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py
new file mode 100644
index 000000000..684f55ad5
--- /dev/null
+++ b/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//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'),
+ path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'),
+ path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'),
+ path('wireless-lan-groups//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//', views.WirelessLANView.as_view(), name='wirelesslan'),
+ path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'),
+ path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'),
+ path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}),
+ path('wireless-lans//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//', views.WirelessLinkView.as_view(), name='wirelesslink'),
+ path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'),
+ path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'),
+ path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}),
+ path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}),
+
+)
diff --git a/netbox/wireless/utils.py b/netbox/wireless/utils.py
new file mode 100644
index 000000000..d98d6a853
--- /dev/null
+++ b/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]
diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py
new file mode 100644
index 000000000..dd1e760bb
--- /dev/null
+++ b/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