mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 10:08:44 -06:00
Compare commits
11 Commits
2eb42d4907
...
fix_module
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95caffb164 | ||
|
|
233e623783 | ||
|
|
8c4ba36319 | ||
|
|
a1c3eb2b1d | ||
|
|
82f6892d24 | ||
|
|
ebada4bf72 | ||
|
|
c78b8401dc | ||
|
|
edf35e35be | ||
|
|
062a871521 | ||
|
|
07d8157ccd | ||
|
|
712c743bcb |
@@ -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.)
|
||||
|
||||
|
||||
17
netbox/circuits/migrations/0056_gfk_indexes.py
Normal file
17
netbox/circuits/migrations/0056_gfk_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -79,6 +79,41 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
MODULE_TOKEN_SEPARATOR = '/'
|
||||
|
||||
|
||||
def resolve_module_token(text, positions):
|
||||
"""
|
||||
Substitute {module} tokens in text with position values.
|
||||
|
||||
Args:
|
||||
text: String potentially containing {module} tokens
|
||||
positions: List of position strings from the module tree (root to leaf)
|
||||
|
||||
Returns:
|
||||
Text with {module} tokens replaced according to these rules:
|
||||
- Single token: replaced with full path (positions joined by MODULE_TOKEN_SEPARATOR)
|
||||
- Multiple tokens: replaced level-by-level (first token gets first position, etc.)
|
||||
|
||||
This centralizes the substitution logic used by both ModuleCommonForm.clean()
|
||||
and ModularComponentTemplateModel.resolve_*() methods.
|
||||
"""
|
||||
if not text or MODULE_TOKEN not in text:
|
||||
return text
|
||||
|
||||
token_count = text.count(MODULE_TOKEN)
|
||||
|
||||
if token_count == 1:
|
||||
# Single token: substitute with full path (e.g., "1/1" for depth 2)
|
||||
full_path = MODULE_TOKEN_SEPARATOR.join(positions)
|
||||
return text.replace(MODULE_TOKEN, full_path, 1)
|
||||
else:
|
||||
# Multiple tokens: substitute level-by-level (existing behavior)
|
||||
result = text
|
||||
for pos in positions:
|
||||
result = result.replace(MODULE_TOKEN, pos, 1)
|
||||
return result
|
||||
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
|
||||
@@ -126,18 +126,22 @@ 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)
|
||||
# A single token which gets expanded to the full path is always
|
||||
# allowed; otherwise the number of tokens needs to match the path length.
|
||||
if token_count != 1 and token_count != len(module_bays):
|
||||
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)
|
||||
# Use centralized helper for token substitution
|
||||
positions = [mb.position for mb in module_bays]
|
||||
resolved_name = resolve_module_token(resolved_name, positions)
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
|
||||
19
netbox/dcim/migrations/0225_gfk_indexes.py
Normal file
19
netbox/dcim/migrations/0225_gfk_indexes.py
Normal 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'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -170,29 +170,33 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
return modules
|
||||
|
||||
def resolve_name(self, module):
|
||||
if MODULE_TOKEN not in self.name:
|
||||
return self.name
|
||||
|
||||
"""Resolve {module} placeholder(s) in component name."""
|
||||
if module:
|
||||
modules = self._get_module_tree(module)
|
||||
name = self.name
|
||||
for module in modules:
|
||||
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return name
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_token(self.name, positions)
|
||||
return self.name
|
||||
|
||||
def resolve_label(self, module):
|
||||
if MODULE_TOKEN not in self.label:
|
||||
return self.label
|
||||
|
||||
"""Resolve {module} placeholder(s) in component label."""
|
||||
if module:
|
||||
modules = self._get_module_tree(module)
|
||||
label = self.label
|
||||
for module in modules:
|
||||
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return label
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_token(self.label, positions)
|
||||
return self.label
|
||||
|
||||
def resolve_position(self, position, module):
|
||||
"""
|
||||
Resolve {module} placeholder in position field.
|
||||
|
||||
This is used by ModuleBayTemplate to resolve positions like "{module}/1"
|
||||
to actual values like "A/1" when the parent module is installed in bay "A".
|
||||
|
||||
Fixes Issue #20467.
|
||||
"""
|
||||
if module:
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_token(position, positions)
|
||||
return position
|
||||
|
||||
|
||||
class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -722,10 +726,11 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
module = kwargs.get('module')
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
name=self.resolve_name(module),
|
||||
label=self.resolve_label(module),
|
||||
position=self.resolve_position(self.position, module),
|
||||
**kwargs
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -848,6 +848,805 @@ 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_module_bay_position_resolves_placeholder(self):
|
||||
"""
|
||||
Test that the position field of instantiated module bays resolves {module} placeholder.
|
||||
|
||||
Issue #20467: When a module type has module bay templates with position="{module}/1",
|
||||
the instantiated module bay should have position="A/1" (not literal "{module}/1").
|
||||
|
||||
This test should:
|
||||
- FAIL on main branch (bug present: position contains "{module}")
|
||||
- PASS after fix (position is resolved to actual value)
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
# Create device type with module bay at position 'A'
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Position Test Chassis',
|
||||
slug='position-test-chassis'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Bay A',
|
||||
position='A'
|
||||
)
|
||||
|
||||
# Create module type with nested bays using {module} in POSITION field
|
||||
extension_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Position Test Extension'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=extension_type,
|
||||
name='Sub Bay {module}-1',
|
||||
label='{module}-1',
|
||||
position='{module}/1' # This should resolve to "A/1"
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=extension_type,
|
||||
name='Sub Bay {module}-2',
|
||||
label='{module}-2',
|
||||
position='{module}/2' # This should resolve to "A/2"
|
||||
)
|
||||
|
||||
# Create device
|
||||
device = Device.objects.create(
|
||||
name='Position Test Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
# Install extension module in Bay A
|
||||
parent_bay = device.modulebays.get(name='Bay A')
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=parent_bay,
|
||||
module_type=extension_type
|
||||
)
|
||||
|
||||
# Verify the nested bays have resolved names (this already works)
|
||||
nested_bay_1 = module.modulebays.get(name='Sub Bay A-1')
|
||||
nested_bay_2 = module.modulebays.get(name='Sub Bay A-2')
|
||||
|
||||
# Verify labels are resolved (this already works)
|
||||
self.assertEqual(nested_bay_1.label, 'A-1')
|
||||
self.assertEqual(nested_bay_2.label, 'A-2')
|
||||
|
||||
# Verify POSITION field is resolved (Issue #20467 - this currently fails)
|
||||
self.assertEqual(nested_bay_1.position, 'A/1')
|
||||
self.assertEqual(nested_bay_2.position, 'A/2')
|
||||
|
||||
# Also verify no {module} literal remains
|
||||
self.assertNotIn('{module}', nested_bay_1.position)
|
||||
self.assertNotIn('{module}', nested_bay_2.position)
|
||||
|
||||
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_mismatched_multi_token_fails_validation(self):
|
||||
"""
|
||||
T2: Multi-token with mismatched depth fails validation (depth=3, tokens=2).
|
||||
Per sigprof's feedback: allowing this would lose position info for level 3.
|
||||
Only single-token (full path) or exact-match multi-token should be allowed.
|
||||
"""
|
||||
from dcim.forms import ModuleForm
|
||||
|
||||
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 (mismatched for depth 3)
|
||||
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 first 2 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
|
||||
)
|
||||
|
||||
# Attempt to install leaf module at depth=3 with 2 tokens - should fail
|
||||
bay3 = module2.modulebays.get(name='Level3 Bay')
|
||||
|
||||
form = ModuleForm(data={
|
||||
'device': device.pk,
|
||||
'module_bay': bay3.pk,
|
||||
'module_type': leaf_type.pk,
|
||||
'status': 'active',
|
||||
'replicate_components': True,
|
||||
'adopt_components': False,
|
||||
})
|
||||
|
||||
# Validation should fail: 2 tokens != 1 and 2 tokens != 3 depth
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('2', str(form.errors))
|
||||
self.assertIn('3', str(form.errors))
|
||||
|
||||
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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
19
netbox/ipam/migrations/0086_gfk_indexes.py
Normal file
19
netbox/ipam/migrations/0086_gfk_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
19
netbox/virtualization/migrations/0052_gfk_indexes.py
Normal file
19
netbox/virtualization/migrations/0052_gfk_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
20
netbox/wireless/migrations/0017_gfk_indexes.py
Normal file
20
netbox/wireless/migrations/0017_gfk_indexes.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user