Compare commits

...

9 Commits

Author SHA1 Message Date
Mark Coleman
b6548d941b Add documentation for {module_path} placeholder
Per arthanson's review request, updated docs/models/dcim/moduletype.md
to document:
- {module} placeholder behavior (single vs multiple use)
- {module_path} placeholder for full path expansion
- Position field resolution for nested module bays
2026-01-20 18:47:02 +01:00
Mark Coleman
898fe8b3d8 Address sigprof's Jan 20 feedback
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
CI / build (20.x, 3.14) (push) Waiting to run
1. Add validation to reject mixing {module} and {module_path} in same attribute
2. Refactor resolve_position() to match resolve_name()/resolve_label() pattern
   - Moved to ModuleBayTemplate where it can access self.position directly
   - No longer takes position as argument
3. Added test for mixed placeholder validation
2026-01-20 10:15:12 +01:00
Mark Coleman
3680b0ccd4 Refactor: move resolve_module_placeholders from constants.py to utils.py
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
Constants should only contain constant values, not functions with logic.
The helper function now lives in dcim/utils.py alongside other utilities
like update_interface_bridges and create_port_mappings.
2026-01-19 19:06:42 +01:00
Mark Coleman
702b1f8210 Implement Option 2: {module_path} for full path, {module} for parent-only
Per sigprof's feedback, this implements two distinct placeholders:

- {module_path}: Always expands to full /-separated path (e.g., 1/2/3)
  Use case: Generic modules like SFPs that work at any depth

- {module} (single): Expands to parent bay position only
  Use case: Building custom paths via position field with user-controlled separators

- {module}/{module}: Level-by-level substitution (unchanged for backwards compat)

This design allows two ways to build module hierarchies:
1. Use {module_path} for automatic path joining (hardcodes / separator)
2. Use position field with {module} for custom separators

Fixes #20474, #20467, #19796
2026-01-19 18:20:59 +01:00
Mark Coleman
1c6adc40b3 Refactor: centralize module token substitution logic
Per sigprof's review feedback, extract the duplicated token substitution
logic into a single resolve_module_token() helper in constants.py.

This addresses two review comments:
1. Duplication between ModuleCommonForm.clean() and resolve_name()
2. Duplication between resolve_name() and resolve_label()

Benefits:
- Single source of truth for substitution logic
- MODULE_TOKEN_SEPARATOR constant for future configurability
- Cleaner, more maintainable code (-7 net lines)
- Easier to modify separator handling in one place
2026-01-19 16:18:54 +01:00
Mark Coleman
bcd3851f4e Address sigprof review: stricter token validation
Per sigprof's feedback, the previous validation (depth >= token_count)
allowed a questionable case where token_count > 1 but < depth, which
would lose position information for some levels.

New validation: token_count must be either 1 (full path expansion) or
exactly match the tree depth (level-by-level substitution).

Updated test T2 to verify this mismatched case is now rejected.
2026-01-19 16:18:54 +01:00
Mark Coleman
850bfba9e4 Fix PEP8: remove trailing whitespace from blank lines 2026-01-19 16:18:54 +01:00
Mark Coleman
1df6eee467 Add position field resolution for module bays (fixes #20467)
- Add resolve_position() method to ModularComponentTemplateModel
- Update ModuleBayTemplate.instantiate() to resolve {module} in position field
- Add test_module_bay_position_resolves_placeholder test

This completes the fix for nested module placeholder issues by ensuring
the position field also resolves {module} placeholders, which is required
for building correct full paths in 3+ level hierarchies.
2026-01-19 16:18:54 +01:00
Mark Coleman
e613b55ada Fix nested module bay placeholder: single {module} resolves to full path (e.g., 1/1) 2026-01-19 16:18:54 +01:00
6 changed files with 1359 additions and 31 deletions

View File

@@ -16,9 +16,33 @@ Note that device bays and module bays may _not_ be added to modules.
## Automatic Component Renaming ## Automatic Component Renaming
When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed. When adding component templates to a module type, placeholders can be used to dynamically incorporate the module bay's `position` field into component names. Two placeholders are available:
For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. ### `{module}` Placeholder
The `{module}` placeholder references the position of the parent module bay:
* **Single use**: Expands to the immediate parent's position only
* **Multiple uses**: Each `{module}` token is replaced level-by-level (the number of tokens must match the nesting depth)
For example, a module type with interface templates named `Gi{module}/0/[1-48]`, when installed in a module bay with position "3", will create interfaces named `Gi3/0/[1-48]`.
### `{module_path}` Placeholder
The `{module_path}` placeholder expands to the full path from the root device to the current module, with positions joined by `/`. This is useful for modules that can be installed at any nesting depth without modification.
For example, consider an SFP module type with an interface template named `eth{module_path}`:
* Installed directly in slot 2: creates interface `eth2`
* Installed in slot 1's nested bay 1: creates interface `eth1/1`
* Installed in slot 1's nested bay 2's sub-bay 3: creates interface `eth1/2/3`
!!! note
`{module_path}` can only be used once per template attribute, and cannot be mixed with `{module}` in the same attribute.
### Position Field Resolution
The `{module}` placeholder can also be used in the `position` field of [module bay templates](./modulebaytemplate.md) defined on a module type. This allows nested module bays to build hierarchical position values. For example, a module bay template with `position="{module}/1"`, when its parent module is installed in a bay with position "2", will have its position resolved to "2/1".
Automatic renaming is supported for all modular component types (those listed above). Automatic renaming is supported for all modular component types (those listed above).

View File

@@ -79,6 +79,8 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
# #
MODULE_TOKEN = '{module}' MODULE_TOKEN = '{module}'
MODULE_PATH_TOKEN = '{module_path}'
MODULE_TOKEN_SEPARATOR = '/'
MODULAR_COMPONENT_TEMPLATE_MODELS = Q( MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim', app_label='dcim',

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.utils import resolve_module_placeholders
from utilities.forms import get_field_value from utilities.forms import get_field_value
__all__ = ( __all__ = (
@@ -119,25 +120,47 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type. # Get the templates for the module type.
for template in getattr(module_type, templates).all(): for template in getattr(module_type, templates).all():
resolved_name = template.name resolved_name = template.name
has_module_token = MODULE_TOKEN in template.name
has_module_path_token = MODULE_PATH_TOKEN in template.name
# Installing modules with placeholders require that the bay has a position value # Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name: if has_module_token or has_module_path_token:
if not module_bay.position: if not module_bay.position:
raise forms.ValidationError( raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.") _("Cannot install module with placeholder values in a module bay with no position defined.")
) )
if len(module_bays) != template.name.count(MODULE_TOKEN): # Cannot mix {module} and {module_path} in the same attribute
if has_module_token and has_module_path_token:
raise forms.ValidationError(
_("Cannot mix {module} and {module_path} placeholders in the same template attribute.")
)
# Validate {module_path} - can only appear once
if has_module_path_token:
path_token_count = template.name.count(MODULE_PATH_TOKEN)
if path_token_count > 1:
raise forms.ValidationError(
_("The {module_path} placeholder can only be used once per template.")
)
# Validate {module} - multi-token must match depth exactly
if has_module_token:
token_count = template.name.count(MODULE_TOKEN)
# Multiple {module} tokens must match the tree depth exactly
if token_count > 1 and token_count != len(module_bays):
raise forms.ValidationError( raise forms.ValidationError(
_( _(
"Cannot install module with placeholder values in a module bay tree {level} in tree " "Cannot install module with placeholder values in a module bay tree {level} deep "
"but {tokens} placeholders given." "but {tokens} placeholders given."
).format( ).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN) level=len(module_bays), tokens=token_count
) )
) )
for module_bay in module_bays: # Use centralized helper for placeholder substitution
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1) positions = [mb.position for mb in module_bays]
resolved_name = resolve_module_placeholders(resolved_name, positions)
existing_item = installed_components.get(resolved_name) existing_item = installed_components.get(resolved_name)

View File

@@ -8,6 +8,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models.base import PortMappingBase from dcim.models.base import PortMappingBase
from dcim.utils import resolve_module_placeholders
from dcim.models.mixins import InterfaceValidationMixin from dcim.models.mixins import InterfaceValidationMixin
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@@ -170,27 +171,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return modules return modules
def resolve_name(self, module): def resolve_name(self, module):
if MODULE_TOKEN not in self.name: """Resolve {module} and {module_path} placeholders in component name."""
return self.name
if module: if module:
modules = self._get_module_tree(module) positions = [m.module_bay.position for m in self._get_module_tree(module)]
name = self.name return resolve_module_placeholders(self.name, positions)
for module in modules:
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
return name
return self.name return self.name
def resolve_label(self, module): def resolve_label(self, module):
if MODULE_TOKEN not in self.label: """Resolve {module} and {module_path} placeholders in component label."""
return self.label
if module: if module:
modules = self._get_module_tree(module) positions = [m.module_bay.position for m in self._get_module_tree(module)]
label = self.label return resolve_module_placeholders(self.label, positions)
for module in modules:
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
return label
return self.label return self.label
@@ -721,11 +712,26 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name = _('module bay template') verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates') verbose_name_plural = _('module bay templates')
def resolve_position(self, module):
"""
Resolve {module} and {module_path} placeholders in position field.
This allows positions like "{module}/1" to resolve to "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_placeholders(self.position, positions)
return self.position
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
module = kwargs.get('module')
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(module),
label=self.resolve_label(kwargs.get('module')), label=self.resolve_label(module),
position=self.position, position=self.resolve_position(module),
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True instantiate.do_not_call_in_templates = True

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,54 @@ from django.apps import apps
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction from django.db import router, transaction
from dcim.constants import MODULE_PATH_TOKEN, MODULE_TOKEN, MODULE_TOKEN_SEPARATOR
def resolve_module_placeholders(text, positions):
"""
Substitute {module} and {module_path} placeholders in text with position values.
Args:
text: String potentially containing {module} or {module_path} placeholders
positions: List of position strings from the module tree (root to leaf)
Returns:
Text with placeholders replaced according to these rules:
{module_path}: Always expands to full path (positions joined by MODULE_TOKEN_SEPARATOR).
Can only appear once in the text.
{module}: If used once, expands to the PARENT module bay position only (last in positions).
If used multiple times, each token is replaced level-by-level.
This design (Option 2 per sigprof's feedback) allows two approaches:
1. Use {module_path} for automatic full-path expansion (hardcodes '/' separator)
2. Use {module} in position fields to build custom paths with user-controlled separators
"""
if not text:
return text
result = text
# Handle {module_path} - always expands to full path
if MODULE_PATH_TOKEN in result:
full_path = MODULE_TOKEN_SEPARATOR.join(positions) if positions else ''
result = result.replace(MODULE_PATH_TOKEN, full_path)
# Handle {module} - parent-only for single token, level-by-level for multiple
if MODULE_TOKEN in result:
token_count = result.count(MODULE_TOKEN)
if token_count == 1 and positions:
# Single {module}: substitute with parent (immediate) bay position only
parent_position = positions[-1] if positions else ''
result = result.replace(MODULE_TOKEN, parent_position, 1)
else:
# Multiple {module}: substitute level-by-level (existing behavior)
for pos in positions:
result = result.replace(MODULE_TOKEN, pos, 1)
return result
def compile_path_node(ct_id, object_id): def compile_path_node(ct_id, object_id):
return f'{ct_id}:{object_id}' return f'{ct_id}:{object_id}'