Compare commits

...

9 Commits

Author SHA1 Message Date
Mark Coleman
82f6892d24 Fix nested module bay placeholder: single {module} resolves to full path (e.g., 1/1) 2026-01-05 22:08:18 +01:00
Jeremy Stretch
ebada4bf72 Closes #21001: Annotate plugin filterset registration in v4.5 release notes (#21058)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-31 09:42:47 -06:00
Jeremy Stretch
c78b8401dc Fixes #21020: Fix object filtering for image attachments panel (#21030)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-29 15:19:24 -06:00
bctiemann
edf35e35be Merge pull request #21028 from netbox-community/fix/device-api-missing-owner-field
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Fix missing owner field in DeviceWithConfigContextSerializer
2025-12-22 14:28:58 -05:00
Jeremy Stretch
062a871521 Add missing owner field to device & VM component serializers
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2025-12-22 13:52:39 -05:00
Mark Coleman
07d8157ccd Fix missing owner field in DeviceWithConfigContextSerializer
Fixes: https://github.com/netbox-community/netbox/issues/21022
2025-12-20 11:02:36 +01:00
Jeremy Stretch
712c743bcb Closes #20954: Add indexes for GFKs (#21015)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 14:49:00 -08:00
Jeremy Stretch
2eb42d4907 Fixes #20997: Enable creating permissions for the Owner model (#21009)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 09:19:40 -08:00
bctiemann
a28269b73a Closes: #20930 - Add an ASNSiteSerializer to allow serialization of Site in ASNSerializer (#20991) 2025-12-17 09:18:51 -08:00
20 changed files with 942 additions and 49 deletions

View File

@@ -22,7 +22,7 @@
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter.
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0055_add_comments_to_organizationalmodel'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0224_add_comments_to_organizationalmodel'),
('extras', '0134_owner'),
]
operations = [
migrations.AddIndex(
model_name='circuittermination',
index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
),
]

View File

@@ -335,6 +335,9 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side'
),
)
indexes = (
models.Index(fields=('termination_type', 'termination_id')),
)
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')

View File

@@ -14,6 +14,7 @@ from ipam.models import VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import NetBoxModelSerializer
from users.api.serializers_.mixins import OwnerMixin
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
@@ -40,7 +41,12 @@ __all__ = (
)
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsoleServerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -64,13 +70,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsolePortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -94,13 +105,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -120,13 +136,18 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerOutletSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -159,12 +180,17 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class InterfaceSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
vdcs = SerializedPKRelatedField(
queryset=VirtualDeviceContext.objects.all(),
@@ -226,7 +252,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -307,7 +333,7 @@ class RearPortMappingSerializer(serializers.ModelSerializer):
fields = ('position', 'front_port', 'front_port_position')
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -327,7 +353,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSeri
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -345,7 +371,7 @@ class FrontPortMappingSerializer(serializers.ModelSerializer):
fields = ('position', 'rear_port', 'rear_port_position')
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -365,12 +391,12 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSer
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(NetBoxModelSerializer):
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -390,12 +416,12 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer):
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
@@ -403,12 +429,12 @@ class DeviceBaySerializer(NetBoxModelSerializer):
model = DeviceBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
'tags', 'custom_fields', 'created', 'last_updated',
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer):
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@@ -427,6 +453,6 @@ class InventoryItemSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')

View File

@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',

View File

@@ -126,18 +126,26 @@ class ModuleCommonForm(forms.Form):
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
if len(module_bays) != template.name.count(MODULE_TOKEN):
token_count = template.name.count(MODULE_TOKEN)
# Validate: depth must be >= token_count (can't expand tokens without context)
if len(module_bays) < token_count:
raise forms.ValidationError(
_(
"Cannot install module with placeholder values in a module bay tree {level} in tree "
"but {tokens} placeholders given."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
level=len(module_bays), tokens=token_count
)
)
for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
if token_count == 1:
# Single token: substitute with full path (e.g., "1/1" for depth 2)
full_path = '/'.join([mb.position for mb in module_bays])
resolved_name = resolved_name.replace(MODULE_TOKEN, full_path, 1)
else:
# Multiple tokens: substitute level-by-level (existing behavior)
for mb in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, mb.position, 1)
existing_item = installed_components.get(resolved_name)

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0224_add_comments_to_organizationalmodel'),
('extras', '0134_owner'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='macaddress',
index=models.Index(
fields=['assigned_object_type', 'assigned_object_id'], name='dcim_macadd_assigne_54115d_idx'
),
),
]

View File

@@ -175,9 +175,16 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
if module:
modules = self._get_module_tree(module)
token_count = self.name.count(MODULE_TOKEN)
name = self.name
for module in modules:
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
if token_count == 1:
# Single token: substitute with full path (e.g., "1/1" for depth 2)
full_path = '/'.join([m.module_bay.position for m in modules])
name = name.replace(MODULE_TOKEN, full_path, 1)
else:
# Multiple tokens: substitute level-by-level (existing behavior)
for m in modules:
name = name.replace(MODULE_TOKEN, m.module_bay.position, 1)
return name
return self.name
@@ -187,9 +194,16 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
if module:
modules = self._get_module_tree(module)
token_count = self.label.count(MODULE_TOKEN)
label = self.label
for module in modules:
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
if token_count == 1:
# Single token: substitute with full path (e.g., "1/1" for depth 2)
full_path = '/'.join([m.module_bay.position for m in modules])
label = label.replace(MODULE_TOKEN, full_path, 1)
else:
# Multiple tokens: substitute level-by-level (existing behavior)
for m in modules:
label = label.replace(MODULE_TOKEN, m.module_bay.position, 1)
return label
return self.label

View File

@@ -1318,7 +1318,10 @@ class MACAddress(PrimaryModel):
)
class Meta:
ordering = ('mac_address', 'pk',)
ordering = ('mac_address', 'pk')
indexes = (
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')

View File

@@ -848,6 +848,720 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
def test_nested_module_single_placeholder_full_path(self):
"""
Test that installing a module at depth=2 with a single {module} placeholder
in the interface template name resolves to the full path (e.g., "1/1").
Regression test for transceiver modeling use case.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
# Create device type with module bay template
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Chassis Device',
slug='chassis-device'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Line Card Bay 1',
position='1'
)
# Create line card module type with nested module bay
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='SFP Bay {module}/1',
label='SFP {module}/1',
position='1'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='SFP Bay {module}/2',
label='SFP {module}/2',
position='2'
)
# Create SFP module type with interface using single {module} placeholder
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='SFP Transceiver'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}',
label='{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device
device = Device.objects.create(
name='Test Chassis',
device_type=device_type,
role=device_role,
site=site
)
# Install line card in bay 1
line_card_bay = device.modulebays.get(name='Line Card Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=line_card_bay,
module_type=line_card_type
)
# Install SFP in nested bay 1 (depth=2)
sfp_bay_1 = line_card.modulebays.get(name='SFP Bay 1/1')
sfp_module_1 = Module.objects.create(
device=device,
module_bay=sfp_bay_1,
module_type=sfp_type
)
# Verify interface name resolves to full path "1/1"
interface_1 = sfp_module_1.interfaces.first()
self.assertEqual(interface_1.name, 'SFP 1/1')
self.assertEqual(interface_1.label, '1/1')
# Install second SFP in nested bay 2 (depth=2) - verifies uniqueness
sfp_bay_2 = line_card.modulebays.get(name='SFP Bay 1/2')
sfp_module_2 = Module.objects.create(
device=device,
module_bay=sfp_bay_2,
module_type=sfp_type
)
# Verify second interface name resolves to full path "1/2"
interface_2 = sfp_module_2.interfaces.first()
self.assertEqual(interface_2.name, 'SFP 1/2')
self.assertEqual(interface_2.label, '1/2')
def test_single_placeholder_direct_install_depth_1(self):
"""
Test that installing a module directly at depth=1 with a single {module}
placeholder still resolves correctly (just the position, not a path).
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
# Create device type with module bay template
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Simple Chassis',
slug='simple-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='SFP Bay 1',
position='1'
)
# Create SFP module type with interface using single {module} placeholder
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Direct SFP'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}',
label='{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device
device = Device.objects.create(
name='Test Simple Chassis',
device_type=device_type,
role=device_role,
site=site
)
# Install SFP directly in bay 1 (depth=1)
sfp_bay = device.modulebays.get(name='SFP Bay 1')
sfp_module = Module.objects.create(
device=device,
module_bay=sfp_bay,
module_type=sfp_type
)
# Verify interface name resolves to just "1"
interface = sfp_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 1')
self.assertEqual(interface.label, '1')
def test_multi_token_level_by_level_depth_2(self):
"""
T1: Multi-token behavior remains unchanged at depth=2.
Ensure legacy {module}/{module} still resolves level-by-level.
"""
site = Site.objects.create(name='T1 Site', slug='t1-site')
manufacturer = Manufacturer.objects.create(name='T1 Manufacturer', slug='t1-manufacturer')
device_role = DeviceRole.objects.create(name='T1 Role', slug='t1-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T1 Chassis',
slug='t1-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create line card module type with nested bay
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T1 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay 2',
position='2'
)
# Create SFP module type with 2-token interface template
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T1 SFP'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}/{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device and install modules
device = Device.objects.create(
name='T1 Device',
device_type=device_type,
role=device_role,
site=site
)
# Install line card at position 1
line_card_bay = device.modulebays.get(name='Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=line_card_bay,
module_type=line_card_type
)
# Install SFP at nested bay (position 2)
sfp_bay = line_card.modulebays.get(name='Nested Bay 2')
sfp_module = Module.objects.create(
device=device,
module_bay=sfp_bay,
module_type=sfp_type
)
# Verify level-by-level substitution: 1/2 (not 1/2/1/2)
interface = sfp_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 1/2')
def test_multi_token_deeper_tree_only_consumes_tokens(self):
"""
T2: Multi-token with deeper tree only consumes tokens (depth=3, tokens=2).
2 tokens → 2 levels, even if tree is deeper.
"""
site = Site.objects.create(name='T2 Site', slug='t2-site')
manufacturer = Manufacturer.objects.create(name='T2 Manufacturer', slug='t2-manufacturer')
device_role = DeviceRole.objects.create(name='T2 Role', slug='t2-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T2 Chassis',
slug='t2-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create level 2 module type with nested bay
level2_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T2 Level2'
)
ModuleBayTemplate.objects.create(
module_type=level2_type,
name='Level2 Bay',
position='1'
)
# Create level 3 module type with nested bay
level3_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T2 Level3'
)
ModuleBayTemplate.objects.create(
module_type=level3_type,
name='Level3 Bay',
position='1'
)
# Create leaf module type with 2-token interface template
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T2 Leaf'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='SFP {module}/{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device and install 3 levels of modules
device = Device.objects.create(
name='T2 Device',
device_type=device_type,
role=device_role,
site=site
)
# Level 1
bay1 = device.modulebays.get(name='Bay 1')
module1 = Module.objects.create(
device=device,
module_bay=bay1,
module_type=level2_type
)
# Level 2
bay2 = module1.modulebays.get(name='Level2 Bay')
module2 = Module.objects.create(
device=device,
module_bay=bay2,
module_type=level3_type
)
# Level 3 (leaf)
bay3 = module2.modulebays.get(name='Level3 Bay')
leaf_module = Module.objects.create(
device=device,
module_bay=bay3,
module_type=leaf_type
)
# Verify: 2 tokens → consumes first 2 levels only: "1/1" (not "1/1/1")
interface = leaf_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 1/1')
def test_too_many_tokens_fails_validation(self):
"""
T3: Too-many-tokens still fails (depth=2, tokens=3).
Confirms the validation prevents impossible substitution.
"""
from dcim.forms import ModuleForm
site = Site.objects.create(name='T3 Site', slug='t3-site')
manufacturer = Manufacturer.objects.create(name='T3 Manufacturer', slug='t3-manufacturer')
device_role = DeviceRole.objects.create(name='T3 Role', slug='t3-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T3 Chassis',
slug='t3-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create line card module type with nested bay
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T3 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay',
position='1'
)
# Create leaf module type with 3-token interface template (too many!)
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T3 Leaf'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='{module}/{module}/{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device and install line card
device = Device.objects.create(
name='T3 Device',
device_type=device_type,
role=device_role,
site=site
)
bay1 = device.modulebays.get(name='Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=bay1,
module_type=line_card_type
)
# Attempt to install leaf module at depth=2 with 3 tokens - should fail
nested_bay = line_card.modulebays.get(name='Nested Bay')
form = ModuleForm(data={
'device': device.pk,
'module_bay': nested_bay.pk,
'module_type': leaf_type.pk,
'status': 'active',
'replicate_components': True,
'adopt_components': False,
})
self.assertFalse(form.is_valid())
# Check the error message mentions the mismatch
self.assertIn('2', str(form.errors))
self.assertIn('3', str(form.errors))
def test_label_substitution_matches_name_depth_2(self):
"""
T4: Label substitution works the same way as name (depth=2 single-token).
"""
site = Site.objects.create(name='T4 Site', slug='t4-site')
manufacturer = Manufacturer.objects.create(name='T4 Manufacturer', slug='t4-manufacturer')
device_role = DeviceRole.objects.create(name='T4 Role', slug='t4-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T4 Chassis',
slug='t4-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create line card module type with nested bay at position 2
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T4 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay',
position='2'
)
# Create leaf module type with single-token name AND label
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T4 Leaf'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='SFP {module}',
label='LBL {module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
# Create device and install modules
device = Device.objects.create(
name='T4 Device',
device_type=device_type,
role=device_role,
site=site
)
bay1 = device.modulebays.get(name='Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=bay1,
module_type=line_card_type
)
nested_bay = line_card.modulebays.get(name='Nested Bay')
leaf_module = Module.objects.create(
device=device,
module_bay=nested_bay,
module_type=leaf_type
)
# Verify both name and label resolve to full path
interface = leaf_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 1/2')
self.assertEqual(interface.label, 'LBL 1/2')
def test_non_interface_component_template_substitution(self):
"""
T5: Non-interface modular component templates (ConsolePortTemplate).
Ensures the fix is general to all ModularComponentTemplateModel subclasses.
"""
site = Site.objects.create(name='T5 Site', slug='t5-site')
manufacturer = Manufacturer.objects.create(name='T5 Manufacturer', slug='t5-manufacturer')
device_role = DeviceRole.objects.create(name='T5 Role', slug='t5-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T5 Chassis',
slug='t5-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create line card module type with nested bay at position 2
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T5 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay',
position='2'
)
# Create leaf module type with ConsolePortTemplate using single token
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T5 Leaf'
)
ConsolePortTemplate.objects.create(
module_type=leaf_type,
name='Console {module}',
label='{module}'
)
# Create device and install modules
device = Device.objects.create(
name='T5 Device',
device_type=device_type,
role=device_role,
site=site
)
bay1 = device.modulebays.get(name='Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=bay1,
module_type=line_card_type
)
nested_bay = line_card.modulebays.get(name='Nested Bay')
leaf_module = Module.objects.create(
device=device,
module_bay=nested_bay,
module_type=leaf_type
)
# Verify ConsolePort resolves with full path
console_port = leaf_module.consoleports.first()
self.assertEqual(console_port.name, 'Console 1/2')
self.assertEqual(console_port.label, '1/2')
def test_positions_with_slashes_join_correctly(self):
"""
T6: Positions that already contain slashes don't break joining (depth=2, single token).
Some platforms use positions like 0/1 (PIC/port style) even before nesting.
"""
site = Site.objects.create(name='T6 Site', slug='t6-site')
manufacturer = Manufacturer.objects.create(name='T6 Manufacturer', slug='t6-manufacturer')
device_role = DeviceRole.objects.create(name='T6 Role', slug='t6-role')
# Create device type with module bay using slash in position
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T6 Chassis',
slug='t6-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='PIC Bay',
position='0/1' # Position already contains slash
)
# Create line card module type with nested bay at position 2
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T6 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay',
position='2'
)
# Create leaf module type with single-token interface template
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T6 Leaf'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='Gi{module}',
type=InterfaceTypeChoices.TYPE_1GE_FIXED
)
# Create device and install modules
device = Device.objects.create(
name='T6 Device',
device_type=device_type,
role=device_role,
site=site
)
bay1 = device.modulebays.get(name='PIC Bay')
line_card = Module.objects.create(
device=device,
module_bay=bay1,
module_type=line_card_type
)
nested_bay = line_card.modulebays.get(name='Nested Bay')
leaf_module = Module.objects.create(
device=device,
module_bay=nested_bay,
module_type=leaf_type
)
# Verify: 0/1 + 2 = 0/1/2
interface = leaf_module.interfaces.first()
self.assertEqual(interface.name, 'Gi0/1/2')
def test_depth_1_single_token_no_extra_slashes(self):
"""
T7: Ensure depth=1 single-token still resolves to the position, not an unnecessary "path join".
"""
site = Site.objects.create(name='T7 Site', slug='t7-site')
manufacturer = Manufacturer.objects.create(name='T7 Manufacturer', slug='t7-manufacturer')
device_role = DeviceRole.objects.create(name='T7 Role', slug='t7-role')
# Create device type with module bay at position 7
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T7 Chassis',
slug='t7-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 7',
position='7'
)
# Create module type with single-token template
module_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T7 Module'
)
InterfaceTemplate.objects.create(
module_type=module_type,
name='{module}',
type=InterfaceTypeChoices.TYPE_1GE_FIXED
)
# Create device and install module directly at depth=1
device = Device.objects.create(
name='T7 Device',
device_type=device_type,
role=device_role,
site=site
)
bay = device.modulebays.get(name='Bay 7')
module = Module.objects.create(
device=device,
module_bay=bay,
module_type=module_type
)
# Verify: just "7", not "7/" or similar
interface = module.interfaces.first()
self.assertEqual(interface.name, '7')
def test_multi_occurrence_tokens_level_by_level(self):
"""
T8: Multiple occurrences of {module} in a single template (token_count > 1) still level-by-level.
Ensure the token_count logic and replacement loop behaves with duplicated patterns.
"""
site = Site.objects.create(name='T8 Site', slug='t8-site')
manufacturer = Manufacturer.objects.create(name='T8 Manufacturer', slug='t8-manufacturer')
device_role = DeviceRole.objects.create(name='T8 Role', slug='t8-role')
# Create device type with module bay
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='T8 Chassis',
slug='t8-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
# Create line card module type with nested bay at position 2
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T8 Line Card'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='Nested Bay',
position='2'
)
# Create leaf module type with 2-token template (non-slash separator)
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='T8 Leaf'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='X{module}-Y{module}',
type=InterfaceTypeChoices.TYPE_1GE_FIXED
)
# Create device and install modules
device = Device.objects.create(
name='T8 Device',
device_type=device_type,
role=device_role,
site=site
)
bay1 = device.modulebays.get(name='Bay 1')
line_card = Module.objects.create(
device=device,
module_bay=bay1,
module_type=line_card_type
)
nested_bay = line_card.modulebays.get(name='Nested Bay')
leaf_module = Module.objects.create(
device=device,
module_bay=nested_bay,
module_type=leaf_type
)
# Verify: X1-Y2 (level-by-level, not full-path stuffed into first)
interface = leaf_module.interfaces.first()
self.assertEqual(interface.name, 'X1-Y2')
class CableTestCase(TestCase):

View File

@@ -51,7 +51,14 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel):
]
def __init__(self, **kwargs):
super().__init__('extras.imageattachment', **kwargs)
super().__init__(
'extras.imageattachment',
filters={
'object_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'object_id': lambda ctx: ctx['object'].pk,
},
**kwargs,
)
class TagsPanel(panels.ObjectPanel):

View File

@@ -1,13 +1,15 @@
from rest_framework import serializers
from dcim.models import Site
from ipam.models import ASN, ASNRange, RIR
from netbox.api.fields import RelatedObjectCountField
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
__all__ = (
'ASNRangeSerializer',
'ASNSerializer',
'ASNSiteSerializer',
'AvailableASNSerializer',
'RIRSerializer',
)
@@ -41,9 +43,27 @@ class ASNRangeSerializer(OrganizationalModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ASNSiteSerializer(PrimaryModelSerializer):
"""
This serializer is meant for inclusion in ASNSerializer and is only used
to avoid a circular import of SiteSerializer.
"""
class Meta:
model = Site
fields = ('id', 'url', 'display', 'name', 'description', 'slug')
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
class ASNSerializer(PrimaryModelSerializer):
rir = RIRSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=ASNSiteSerializer,
nested=True,
required=False,
many=True
)
# Related object counts
site_count = RelatedObjectCountField('sites')
@@ -53,7 +73,7 @@ class ASNSerializer(PrimaryModelSerializer):
model = ASN
fields = [
'id', 'url', 'display_url', 'display', 'asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count',
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites',
]
brief_fields = ('id', 'url', 'display', 'asn', 'description')

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('ipam', '0085_add_comments_to_organizationalmodel'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='prefix',
index=models.Index(fields=['scope_type', 'scope_id'], name='ipam_prefix_scope_t_fe84a6_idx'),
),
]

View File

@@ -282,13 +282,10 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
verbose_name = _('prefix')
verbose_name_plural = _('prefixes')
indexes = [
GistIndex(
fields=['prefix'],
name='ipam_prefix_gist_idx',
opclasses=['inet_ops'],
),
]
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -5,7 +5,7 @@ from django.db.models import Q
OBJECTPERMISSION_OBJECT_TYPES = Q(
~Q(app_label__in=['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) |
Q(app_label='users', model__in=['objectpermission', 'token', 'group', 'user'])
Q(app_label='users', model__in=['objectpermission', 'token', 'group', 'user', 'owner'])
)
CONSTRAINT_TOKEN_USER = '$user'

View File

@@ -15,6 +15,7 @@ from ipam.models import VLAN
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from users.api.serializers_.mixins import OwnerMixin
from virtualization.choices import *
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
@@ -65,8 +66,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
'disk', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -78,7 +79,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
# VM interfaces
#
class VMInterfaceSerializer(NetBoxModelSerializer):
class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
virtual_machine = VirtualMachineSerializer(nested=True)
parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
@@ -107,7 +108,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
@@ -147,13 +148,13 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
# Virtual Disk
#
class VirtualDiskSerializer(NetBoxModelSerializer):
class VirtualDiskSerializer(OwnerMixin, NetBoxModelSerializer):
virtual_machine = VirtualMachineSerializer(nested=True)
class Meta:
model = VirtualDisk
fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags',
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'description', 'size', 'owner', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size')

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
('virtualization', '0051_add_comments_to_organizationalmodel'),
]
operations = [
migrations.AddIndex(
model_name='cluster',
index=models.Index(fields=['scope_type', 'scope_id'], name='virtualizat_scope_t_fb3b6e_idx'),
),
]

View File

@@ -107,6 +107,9 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
name='%(app_label)s_%(class)s_unique__site_name'
),
)
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
)
verbose_name = _('cluster')
verbose_name_plural = _('clusters')

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('ipam', '0086_gfk_indexes'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
('wireless', '0016_owner'),
]
operations = [
migrations.AddIndex(
model_name='wirelesslan',
index=models.Index(fields=['scope_type', 'scope_id'], name='wireless_wi_scope_t_6740a3_idx'),
),
]

View File

@@ -113,6 +113,9 @@ class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
class Meta:
ordering = ('ssid', 'pk')
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
)
verbose_name = _('wireless LAN')
verbose_name_plural = _('wireless LANs')