diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md
index 0f50fa75f..a448c42a2 100644
--- a/docs/models/wireless/wirelesslan.md
+++ b/docs/models/wireless/wirelesslan.md
@@ -43,3 +43,7 @@ The security cipher used to apply wireless authentication. Options include:
### Pre-Shared Key
The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types.
+
+### Scope
+
+The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated.
diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py
index cb2c9ca07..0cf2b424f 100644
--- a/netbox/circuits/migrations/0047_circuittermination__termination.py
+++ b/netbox/circuits/migrations/0047_circuittermination__termination.py
@@ -21,6 +21,7 @@ def copy_site_assignments(apps, schema_editor):
termination_id=models.F('provider_network_id')
)
+
class Migration(migrations.Migration):
dependencies = [
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index a87e327af..f6c626443 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -341,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
-class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
+class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination
@classmethod
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 258df8264..655a5d6ca 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -223,7 +223,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
from extras.scripts import get_module_and_script
module_name, script_name = action_object.split('.', 1)
try:
- module, script = get_module_and_script(module_name, script_name)
+ script = get_module_and_script(module_name, script_name)[1]
except ObjectDoesNotExist:
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = script
diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py
index d5fb435ad..847d89396 100644
--- a/netbox/extras/management/commands/runscript.py
+++ b/netbox/extras/management/commands/runscript.py
@@ -38,7 +38,7 @@ class Command(BaseCommand):
data = {}
module_name, script_name = script.split('.', 1)
- module, script_obj = get_module_and_script(module_name, script_name)
+ script_obj = get_module_and_script(module_name, script_name)[1]
script = script_obj.python_class
# Take user from command line if provided and exists, other
diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py
index 6bfd2c14c..2fa0bf8aa 100644
--- a/netbox/extras/migrations/0109_script_model.py
+++ b/netbox/extras/migrations/0109_script_model.py
@@ -30,7 +30,7 @@ def get_python_name(scriptmodule):
"""
Return the Python name of a ScriptModule's file on disk.
"""
- path, filename = os.path.split(scriptmodule.file_path)
+ filename = os.path.split(scriptmodule.file_path)[0]
return os.path.splitext(filename)[0]
@@ -128,7 +128,7 @@ def update_event_rules(apps, schema_editor):
for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
name = eventrule.action_parameters.get('script_name')
- obj, created = Script.objects.get_or_create(
+ obj, __ = Script.objects.get_or_create(
module_id=eventrule.action_object_id,
name=name,
defaults={'is_executable': False}
diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py
index c6abb5a26..c493b7876 100644
--- a/netbox/ipam/lookups.py
+++ b/netbox/ipam/lookups.py
@@ -108,8 +108,8 @@ class NetIn(Lookup):
return self.rhs
def as_sql(self, qn, connection):
- lhs, lhs_params = self.process_lhs(qn, connection)
- rhs, rhs_params = self.process_rhs(qn, connection)
+ lhs = self.process_lhs(qn, connection)[0]
+ rhs_params = self.process_rhs(qn, connection)[1]
with_mask, without_mask = [], []
for address in rhs_params[0]:
if '/' in address:
diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py
index 8d69af847..fea6b55e2 100644
--- a/netbox/ipam/tests/test_ordering.py
+++ b/netbox/ipam/tests/test_ordering.py
@@ -42,7 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
"""
This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
"""
- vrf1, vrf2, vrf3 = list(VRF.objects.all())
+ vrf1, vrf2 = VRF.objects.all()[:2]
prefixes = (
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')),
@@ -106,7 +106,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
VRF A:10.1.1.0/24
None: 192.168.0.0/16
"""
- vrf1, vrf2, vrf3 = list(VRF.objects.all())
+ vrf1 = VRF.objects.first()
prefixes = [
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')),
@@ -130,7 +130,7 @@ class IPAddressOrderingTestCase(OrderingTestBase):
"""
This function tests ordering with the inclusion of vrfs
"""
- vrf1, vrf2, vrf3 = list(VRF.objects.all())
+ vrf1, vrf2 = VRF.objects.all()[:2]
addresses = (
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')),
diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py
index 7c2df4200..83f699e42 100644
--- a/netbox/netbox/authentication/__init__.py
+++ b/netbox/netbox/authentication/__init__.py
@@ -107,7 +107,7 @@ class ObjectPermissionMixin:
return perms
def has_perm(self, user_obj, perm, obj=None):
- app_label, action, model_name = resolve_permission(perm)
+ app_label, __, model_name = resolve_permission(perm)
# Superusers implicitly have all permissions
if user_obj.is_active and user_obj.is_superuser:
diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py
index db82d0a75..264c8e6f9 100644
--- a/netbox/netbox/tests/test_plugins.py
+++ b/netbox/netbox/tests/test_plugins.py
@@ -213,7 +213,6 @@ class PluginTest(TestCase):
self.assertEqual(get_plugin_config(plugin, 'bar'), None)
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
-
def test_events_pipeline(self):
"""
Check that events pipeline is registered.
diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py
index 9e8ed5a3a..5872a59cd 100644
--- a/netbox/netbox/views/errors.py
+++ b/netbox/netbox/views/errors.py
@@ -49,7 +49,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
template = loader.get_template(template_name)
except TemplateDoesNotExist:
return HttpResponseServerError('
Server Error (500)
', content_type='text/html')
- type_, error, traceback = sys.exc_info()
+ type_, error = sys.exc_info()[:2]
return HttpResponseServerError(template.render({
'error': error,
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html
index 493c36132..54473ea54 100644
--- a/netbox/templates/wireless/wirelesslan.html
+++ b/netbox/templates/wireless/wirelesslan.html
@@ -22,6 +22,14 @@
{% trans "Status" %} |
{% badge object.get_status_display bg_color=object.get_status_color %} |
+
+ {% trans "Scope" %} |
+ {% if object.scope %}
+ {{ object.scope|linkify }} ({% trans object.scope_type.name %}) |
+ {% else %}
+ {{ ''|placeholder }} |
+ {% endif %}
+
{% trans "Description" %} |
{{ object.description|placeholder }} |
diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py
index 5d2a46424..397098ded 100644
--- a/netbox/utilities/error_handlers.py
+++ b/netbox/utilities/error_handlers.py
@@ -49,7 +49,7 @@ def handle_rest_api_exception(request, *args, **kwargs):
"""
Handle exceptions and return a useful error message for REST API requests.
"""
- type_, error, traceback = sys.exc_info()
+ type_, error = sys.exc_info()[:2]
data = {
'error': str(error),
'exception': type_.__name__,
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index 45823065e..668965e8a 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -83,7 +83,7 @@ class CountersTest(TestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self):
- device1, device2 = Device.objects.all()
+ device1 = Device.objects.first()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db()
diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py
index 101a5b5a3..c0b636e33 100644
--- a/netbox/virtualization/api/serializers_/clusters.py
+++ b/netbox/virtualization/api/serializers_/clusters.py
@@ -80,5 +80,3 @@ class ClusterSerializer(NetBoxModelSerializer):
serializer = get_serializer_for_model(obj.scope)
context = {'request': self.context['request']}
return serializer(obj.scope, nested=True, context=context).data
-
-
diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py
index cbd65f609..3085c927c 100644
--- a/netbox/virtualization/graphql/types.py
+++ b/netbox/virtualization/graphql/types.py
@@ -48,7 +48,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
], strawberry.union("ClusterScopeType")] | None:
- return self.scope
+ return self.scope
@strawberry_django.type(
diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py
index 63a888ac3..b7af25f8b 100644
--- a/netbox/virtualization/migrations/0044_cluster_scope.py
+++ b/netbox/virtualization/migrations/0044_cluster_scope.py
@@ -3,17 +3,17 @@ from django.db import migrations, models
def copy_site_assignments(apps, schema_editor):
- """
- Copy site ForeignKey values to the scope GFK.
- """
- ContentType = apps.get_model('contenttypes', 'ContentType')
- Cluster = apps.get_model('virtualization', 'Cluster')
- Site = apps.get_model('dcim', 'Site')
+ """
+ Copy site ForeignKey values to the scope GFK.
+ """
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ Cluster = apps.get_model('virtualization', 'Cluster')
+ Site = apps.get_model('dcim', 'Site')
- Cluster.objects.filter(site__isnull=False).update(
- scope_type=ContentType.objects.get_for_model(Site),
- scope_id=models.F('site_id')
- )
+ Cluster.objects.filter(site__isnull=False).update(
+ scope_type=ContentType.objects.get_for_model(Site),
+ scope_id=models.F('site_id')
+ )
class Migration(migrations.Migration):
diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py
index 6c5deeb26..637089277 100644
--- a/netbox/wireless/api/serializers_/wirelesslans.py
+++ b/netbox/wireless/api/serializers_/wirelesslans.py
@@ -1,9 +1,13 @@
from rest_framework import serializers
+from dcim.constants import LOCATION_SCOPE_TYPES
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
from ipam.api.serializers_.vlans import VLANSerializer
-from netbox.api.fields import ChoiceField
+from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
from wireless.choices import *
from wireless.models import WirelessLAN, WirelessLANGroup
from .nested import NestedWirelessLANGroupSerializer
@@ -34,12 +38,30 @@ class WirelessLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, 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)
+ scope_type = ContentTypeField(
+ queryset=ContentType.objects.filter(
+ model__in=LOCATION_SCOPE_TYPES
+ ),
+ allow_null=True,
+ required=False,
+ default=None
+ )
+ scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+ scope = serializers.SerializerMethodField(read_only=True)
class Meta:
model = WirelessLAN
fields = [
- 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant',
- 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields',
+ 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type', 'scope_id', 'scope',
+ 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'ssid', 'description')
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_scope(self, obj):
+ if obj.scope_id is None:
+ return None
+ serializer = get_serializer_for_model(obj.scope)
+ context = {'request': self.context['request']}
+ return serializer(obj.scope, nested=True, context=context).data
diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py
index 537b2ec5c..5a4195e6c 100644
--- a/netbox/wireless/filtersets.py
+++ b/netbox/wireless/filtersets.py
@@ -2,6 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.choices import LinkStatusChoices
+from dcim.filtersets import ScopedFilterSet
from dcim.models import Interface
from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -43,7 +44,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description')
-class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
group_id = TreeNodeMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all(),
field_name='group',
@@ -74,7 +75,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = WirelessLAN
- fields = ('id', 'ssid', 'auth_psk', 'description')
+ fields = ('id', 'ssid', 'auth_psk', 'scope_id', 'description')
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py
index c8b378104..5cd3a157a 100644
--- a/netbox/wireless/forms/bulk_edit.py
+++ b/netbox/wireless/forms/bulk_edit.py
@@ -2,6 +2,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.forms.mixins import ScopedBulkEditForm
from ipam.models import VLAN
from netbox.choices import *
from netbox.forms import NetBoxModelBulkEditForm
@@ -39,7 +40,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('parent', 'description')
-class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
+class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(WirelessLANStatusChoices),
@@ -89,10 +90,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
model = WirelessLAN
fieldsets = (
FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'),
+ FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
nullable_fields = (
- 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments',
+ 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'scope', 'comments',
)
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
index cff3e49af..f23ccf203 100644
--- a/netbox/wireless/forms/bulk_import.py
+++ b/netbox/wireless/forms/bulk_import.py
@@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.forms.mixins import ScopedImportForm
from dcim.models import Interface
from ipam.models import VLAN
from netbox.choices import *
@@ -32,7 +33,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'parent', 'description', 'tags')
-class WirelessLANImportForm(NetBoxModelImportForm):
+class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm):
group = CSVModelChoiceField(
label=_('Group'),
queryset=WirelessLANGroup.objects.all(),
@@ -75,9 +76,12 @@ class WirelessLANImportForm(NetBoxModelImportForm):
class Meta:
model = WirelessLAN
fields = (
- 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
- 'comments', 'tags',
+ 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', 'scope_id',
+ 'description', 'comments', 'tags',
)
+ labels = {
+ 'scope_id': _('Scope ID'),
+ }
class WirelessLinkImportForm(NetBoxModelImportForm):
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
index 6439a2516..f62a3be06 100644
--- a/netbox/wireless/forms/filtersets.py
+++ b/netbox/wireless/forms/filtersets.py
@@ -2,6 +2,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.models import Location, Region, Site, SiteGroup
from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
@@ -33,6 +34,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
+ FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -65,6 +67,31 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Pre-shared key'),
required=False
)
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'region_id': '$region_id',
+ 'site_group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ location_id = DynamicModelMultipleChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ label=_('Location')
+ )
tag = TagFilterField(model)
diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py
index 7c2594271..877324b8c 100644
--- a/netbox/wireless/forms/model_forms.py
+++ b/netbox/wireless/forms/model_forms.py
@@ -2,6 +2,7 @@ from django.forms import PasswordInput
from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Location, Site
+from dcim.forms.mixins import ScopedForm
from ipam.models import VLAN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
@@ -35,7 +36,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
]
-class WirelessLANForm(TenancyForm, NetBoxModelForm):
+class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=WirelessLANGroup.objects.all(),
@@ -51,6 +52,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
fieldsets = (
FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')),
+ FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -59,7 +61,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
model = WirelessLAN
fields = [
'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
- 'description', 'comments', 'tags',
+ 'scope_type', 'description', 'comments', 'tags',
]
widgets = {
'auth_psk': PasswordInput(
diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py
index b24525fbe..aa44e9b9f 100644
--- a/netbox/wireless/graphql/types.py
+++ b/netbox/wireless/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, Union
import strawberry
import strawberry_django
@@ -28,7 +28,7 @@ class WirelessLANGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.WirelessLAN,
- fields='__all__',
+ exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=WirelessLANFilter
)
class WirelessLANType(NetBoxObjectType):
@@ -38,6 +38,15 @@ class WirelessLANType(NetBoxObjectType):
interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
+ @strawberry_django.field
+ def scope(self) -> Annotated[Union[
+ Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+ ], strawberry.union("WirelessLANScopeType")] | None:
+ return self.scope
+
@strawberry_django.type(
models.WirelessLink,
diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py
new file mode 100644
index 000000000..ea4470641
--- /dev/null
+++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py
@@ -0,0 +1,77 @@
+# Generated by Django 5.0.9 on 2024-11-04 16:00
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('dcim', '0196_qinq_svlan'),
+ ('wireless', '0010_charfield_null_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_location',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.location',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_region',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.region',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_site',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.site',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_site_group',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.sitegroup',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='scope_id',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='scope_type',
+ field=models.ForeignKey(
+ blank=True,
+ limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='contenttypes.contenttype',
+ ),
+ ),
+ ]
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 88c60d494..d78c893a6 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
+from dcim.models.mixins import CachedScopeMixin
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models.mixins import DistanceMixin
from .choices import *
@@ -71,7 +72,7 @@ class WirelessLANGroup(NestedGroupModel):
verbose_name_plural = _('wireless LAN groups')
-class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
+class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
"""
A wireless network formed among an arbitrary number of access point and clients.
"""
@@ -107,7 +108,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
null=True
)
- clone_fields = ('ssid', 'group', 'tenant', 'description')
+ clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description')
class Meta:
ordering = ('ssid', 'pk')
diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py
index 87ad4ac51..40f52f8a5 100644
--- a/netbox/wireless/tables/wirelesslan.py
+++ b/netbox/wireless/tables/wirelesslan.py
@@ -51,6 +51,13 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
+ scope_type = columns.ContentTypeColumn(
+ verbose_name=_('Scope Type'),
+ )
+ scope = tables.Column(
+ verbose_name=_('Scope'),
+ linkify=True
+ )
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
@@ -65,7 +72,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
model = WirelessLAN
fields = (
'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type',
- 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated',
+ 'auth_cipher', 'auth_psk', 'scope', 'scope_type', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count')
diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py
index 4b7545888..f768eafaf 100644
--- a/netbox/wireless/tests/test_api.py
+++ b/netbox/wireless/tests/test_api.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from dcim.choices import InterfaceTypeChoices
-from dcim.models import Interface
+from dcim.models import Interface, Site
from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from wireless.choices import *
@@ -53,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -94,6 +100,8 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
'status': WirelessLANStatusChoices.STATUS_DISABLED,
'tenant': tenants[0].pk,
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE,
+ 'scope_type': 'dcim.site',
+ 'scope_id': sites[1].pk,
},
]
diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py
index 5c932928c..76ef4e220 100644
--- a/netbox/wireless/tests/test_filtersets.py
+++ b/netbox/wireless/tests/test_filtersets.py
@@ -1,7 +1,7 @@
from django.test import TestCase
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
-from dcim.models import Interface
+from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import VLAN
from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant
@@ -110,6 +110,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VLAN.objects.bulk_create(vlans)
+ regions = (
+ Region(name='Test Region 1', slug='test-region-1'),
+ Region(name='Test Region 2', slug='test-region-2'),
+ Region(name='Test Region 3', slug='test-region-3'),
+ )
+ for r in regions:
+ r.save()
+
+ site_groups = (
+ SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 2', slug='site-group-2'),
+ SiteGroup(name='Site Group 3', slug='site-group-3'),
+ )
+ for site_group in site_groups:
+ site_group.save()
+
+ sites = (
+ Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
+ Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
+ Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
+ )
+ Site.objects.bulk_create(sites)
+
+ locations = (
+ Location(name='Location 1', slug='location-1', site=sites[0]),
+ Location(name='Location 2', slug='location-2', site=sites[2]),
+ )
+ for location in locations:
+ location.save()
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -127,7 +157,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1',
- description='foobar1'
+ description='foobar1',
+ scope=sites[0]
),
WirelessLAN(
ssid='WLAN2',
@@ -138,7 +169,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2',
- description='foobar2'
+ description='foobar2',
+ scope=locations[0]
),
WirelessLAN(
ssid='WLAN3',
@@ -149,12 +181,14 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3',
- description='foobar3'
+ description='foobar3',
+ scope=locations[1]
),
)
- WirelessLAN.objects.bulk_create(wireless_lans)
+ for wireless_lan in wireless_lans:
+ wireless_lan.save()
- device = create_test_device('Device 1')
+ device = create_test_device('Device 1', site=sites[0])
interfaces = (
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N),
@@ -217,6 +251,38 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_region(self):
+ regions = Region.objects.all()[:2]
+ params = {'region_id': [regions[0].pk, regions[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'region': [regions[0].slug, regions[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site_group(self):
+ site_groups = SiteGroup.objects.all()[:2]
+ params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site(self):
+ sites = Site.objects.all()[:2]
+ params = {'site_id': [sites[0].pk, sites[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'site': [sites[0].slug, sites[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_location(self):
+ locations = Location.objects.all()[:1]
+ params = {'location_id': [locations[0].pk,]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'location': [locations[0].slug,]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_scope_type(self):
+ params = {'scope_type': 'dcim.location'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLink.objects.all()
diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py
index d28d9fde3..713ba81d7 100644
--- a/netbox/wireless/tests/test_views.py
+++ b/netbox/wireless/tests/test_views.py
@@ -1,7 +1,8 @@
+from django.contrib.contenttypes.models import ContentType
from wireless.choices import *
from wireless.models import *
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
-from dcim.models import Interface
+from dcim.models import Interface, Site
from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -56,6 +57,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -98,15 +105,17 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'ssid': 'WLAN2',
'group': groups[1].pk,
'status': WirelessLANStatusChoices.STATUS_DISABLED,
+ 'scope_type': ContentType.objects.get_for_model(Site).pk,
+ 'scope': sites[1].pk,
'tenant': tenants[1].pk,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
- "group,ssid,status,tenant",
- f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}",
- f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}",
- f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}",
+ "group,ssid,status,tenant,scope_type,scope_id",
+ f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name},,",
+ f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name},dcim.site,{sites[0].pk}",
+ f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name},dcim.site,{sites[1].pk}",
)
cls.csv_update_data = (
diff --git a/ruff.toml b/ruff.toml
index 854404469..94a0e1c61 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -1,2 +1,4 @@
[lint]
+extend-select = ["E1", "E2", "E3", "W"]
ignore = ["E501", "F403", "F405"]
+preview = true