Skip to content

Conversation

@mrmrcoleman
Copy link
Collaborator

@mrmrcoleman mrmrcoleman commented Jan 5, 2026

Summary

Fixes the {module} placeholder resolution for nested module bays and introduces a new {module_path} placeholder for automatic full-path expansion.

Fixes: #20474, #20467, #19796

Problem

The current implementation requires an exact match between the number of {module} tokens in template names and the depth of the module bay tree. This forces users to create duplicate ModuleTypes for each nesting level, breaking the "pluggable transceivers" use case where a single SFP/QSFP module should install at any depth.

Additionally, the {module} placeholder was not resolved in the position field of module bays, leaving literal {module} strings in positions when installing nested modules.

Solution

This PR introduces two distinct placeholders:

{module_path} (NEW)

  • Expands to the full path from root to current position, joined by /
  • Example: At depth 3 with positions "1", "2", "3" → expands to "1/2/3"
  • Use case: Generic modules (SFPs, QSFPs) that work at any nesting depth without modification

{module} (UPDATED BEHAVIOR)

  • Single token: Expands to the immediate parent's position only (not full path)
  • Multiple tokens: Expands level-by-level (unchanged for backwards compatibility)
  • Use case: Building custom paths via position field with user-controlled separators

Why Two Placeholders?

This design supports two complementary workflows:

  1. Simple case: Use {module_path} in component names for automatic path joining
  2. Custom separators: Use {module} in position field to build paths like eth{module}-{position} where users control the separator in the position field itself

Validation Rules

  • Only one {module_path} allowed per template attribute
  • Cannot mix {module} and {module_path} in the same attribute
  • Multiple {module} tokens must match tree depth exactly (existing behavior)

Testing

  • All 8,375 tests pass locally
  • Added comprehensive test coverage for:
    • {module_path} at depths 1, 2, and 3
    • Single {module} parent-only behavior
    • Position field resolution with {module} and {module_path}
    • Validation rules for both placeholders
    • Edge case: nested position resolution without duplication (sigprof's scenario)

Why This Targets feature Branch

This PR targets feature because it introduces new functionality ({module_path} placeholder). However, all changes are backwards compatible:

  • Existing single {module} usage now gives parent position (previously gave full path for depth-1 only)
  • Multiple {module} behavior is unchanged
  • No existing workflows break

To target main for 4.5.x: The behavioral change for single {module} (parent-only vs. full-path) is technically a change, but since the previous behavior was problematic for nested modules anyway, this could be considered a bug fix. Maintainers can decide if this warrants backporting.

Changes

  • dcim/constants.py: Added MODULE_PATH_TOKEN, resolve_module_placeholders() helper
  • dcim/forms/common.py: Updated validation for both placeholder types
  • dcim/models/device_component_templates.py: Uses centralized helper for name/label/position resolution
  • dcim/tests/test_models.py: Comprehensive test coverage for all scenarios
@mrmrcoleman mrmrcoleman changed the title Fix {module} resolution for nested module installs Jan 5, 2026
Copy link

@sigprof sigprof left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues to discuss:

  1. Should we redefine the meaning of {module} (so that a single use of {module} means the whole path, but multiple uses of {module} in the same attribute each expand to a single level of position), or define a new placeholder like {module_path} which must be used only once in the string (and then keep the existing restrictions for {module})? The latter may be better for backwards compatibility, but some people would need to replace {module} with {module_path} in their module types when they encounter devices with multilevel module hierarchy.
  2. How should the separators in the {module_path} expansion be handled? In general, the separator may need to be specified by the device (and in some cases multiple kinds of separators may be needed, like <UNIT>/<SLOT>/<PORT>:<CHANNEL>, although in this case the :<CHANNEL> part may need to be supplied by the transceiver module).
  3. More generally, should the position attribute for a module bay contain only the parent-relative position, or the full path from the device? The current code assumes the first interpretation (therefore expanding {module_path} requires concatenating the position values along the path and adding separators); however, if we choose the second interpretation (which would also require fixing #20467, so that the position attribute for the module bay on a module could reference the position of the parent module bay), we could avoid at least some issues with vendor-specific separators: the modules which correspond to interface cards (which are inevitably vendor-specific) could include all separators in their position values, so that the module bays which accept generic transceiver modules could have their position values always equal to the interface names.
@jeremystretch jeremystretch requested review from a team and arthanson and removed request for a team January 14, 2026 13:23
@mrmrcoleman mrmrcoleman changed the title Fix {module} resolution for nested module installs Jan 19, 2026
@mrmrcoleman mrmrcoleman force-pushed the fix_module_substitution branch from 5503e64 to 95caffb Compare January 19, 2026 15:14
- 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.
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.
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
@sigprof
Copy link

sigprof commented Jan 19, 2026

The suggested fixes for #20474/#19796 and #20467 are somewhat conflicting:

E.g., look at the intended usage in #20467:

  • device
    • module bay (position=2)
      • extension module (name=Extension module {module}, expanded to Extension module 2)
        • module bay (position={module}/1, expanded to 2/1)
          • tape drive module with name = ?:
            • name={module} would expand to 2/2/1, duplicating the position which got inserted into the position of the nested module bay 👎
            • name={module}/{module} would expand to 2/2/1 again 👎

Possible options here:

  1. Decide that position={module}/something is wrong, and a single {module} expanding to a /-separated path is the only supported way. This would mean rejecting The "Position" field doesn't resolve the {module} variable in nested modules #20467 as WONTFIX (the intended hierarchy would still be implementable as the way 1 below, just using {module} instead of {module_path}).
  2. Introduce separate placeholders to make two ways of building the module hierarchy possible:
    • {module} — if used only once in the value, expands to position of just the parent module bay even if there are more nesting levels, otherwise the number of {module} placeholders needs to be equal to the number of nesting levels;
    • {module_path} — expands to the /-separated path of all position values up to the top level of the module tree.
  3. Decide the position={module}/something is the only correct way and change {module} to expand to position of just the parent module bay if used only once in the value.

In the first case modules which can be used at any depth would have the / separator hardcoded (the alternative of specifying multiple {module} placeholders with custom separators would lock the module to the specific number of hierarchy levels).

In the second case there would be two ways to build the hierarchy:

  1. Set the position values for nested module bays to contain only the position on the particular level (do not use the {module} placeholder in position), then use {module_path} in generic modules. This hardcodes the / separators again.
    Example:
    • device
      • module bay (position=1/1) tape drive slot on the base unit
        • tape drive module with name = Tape drive {module_path}, expanded to Tape drive 1/1
      • module bay (position=2) extension module slot
        • extension module (name=Extension module {module}, expanded to Extension module 2)
          • module bay (position=1) tape drive slot on the extension unit
            • tape drive module with name = Tape drive {module_path}, expanded to Tape drive 2/1
      • module bay (position=3) extension module slot
        • extension module (name=Extension module {module}, expanded to Extension module 3)
          • module bay (position=1) tape drive slot on the extension unit
            • tape drive module with name = Tape drive {module_path}, expanded to Tape drive 3/1
  2. Set the position values for nested module bays to contain the whole path from the root device (use the {module} placeholder in position to get the path to the containing module and append the relative position of the bay), then use {module} in generic modules. This does not hardcode the / separators (each module bay can specify a different separator if needed).
    Example:
    • device
      • module bay (position=1/1) tape drive slot on the base unit
        • tape drive module with name = Tape drive {module}, expanded to Tape drive 1/1
      • module bay (position=2) extension module slot
        • extension module (name=Extension module {module}, expanded to Extension module 2)
          • module bay (position={module}/1, expanded to 2/1) tape drive slot on the extension unit
            • tape drive module with name = Tape drive {module}, expanded to Tape drive 2/1
      • module bay (position=3) extension module slot
        • extension module (name=Extension module {module}, expanded to Extension module 3)
          • module bay (position={module}/1, expanded to 3/1) tape drive slot on the extension unit
            • tape drive module with name = Tape drive {module}, expanded to Tape drive 3/1

One problem here is that the choice between these two ways to build the hierarchy needs to be made at the start — if you already have some module types set up for one way, you can't reuse those types without changes for the other way.

In the third case only the way 2 above would be possible if you want to build a multilevel hiearchy with generic modules that accept any number of nested levels.

@mrmrcoleman mrmrcoleman force-pushed the fix_module_substitution branch from 95caffb to 1c6adc4 Compare January 19, 2026 16:33
…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
@mrmrcoleman mrmrcoleman changed the title Fix nested module {module} placeholder resolution (fixes #20474, #20467, #19796) Jan 19, 2026
@mrmrcoleman
Copy link
Collaborator Author

mrmrcoleman commented Jan 19, 2026

@sigprof Your input is appreciated. Please take a look at the recent changes and updated PR description

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.
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
@mrmrcoleman
Copy link
Collaborator Author

@sigprof Thanks for your review. Your feedback is addressed in 898fe8b

Copy link
Collaborator

@arthanson arthanson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add documentation for {module_path} to docs/models/dcim/moduletype.md under ## Automatic Component Renaming

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
@mrmrcoleman mrmrcoleman force-pushed the fix_module_substitution branch from 5550f13 to b6548d9 Compare January 20, 2026 17:47
@mrmrcoleman mrmrcoleman requested a review from arthanson January 20, 2026 18:10
def test_mixed_module_and_module_path_fails_validation(self):
"""
Test that mixing {module} and {module_path} in the same template attribute
fails validation. Per sigprof's feedback, these cannot be combined.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can probably get rid of the "Per sigprof's feedback..."

site = Site.objects.first()
device_role = DeviceRole.objects.first()

device_type = DeviceType.objects.create(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to move device_type creation into setup, it seems to be used in almost every test.

# Single {module} should give PARENT position only: Y (not X/Y)
self.assertEqual(interface.name, 'Port Y')

def test_sigprof_nested_position_no_duplication(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the tests are good but a few things to check:

  1. There looks like some duplication, like checking for depth 1, then 2, then 3, I think just 1 and 3 would work potentially. Also test_nested_module_bay_label_resolution looks like it could be folded into one of the new tests.
  2. I'd re-order these to make them easier to check in the future - put all the {module} tests first, then the {module_path} test next, then the combined ones after. keep the simple 'test single {module}' first then test multi module after it, it is sort of like this at start but then the tests get jumbled together. Move test_single_module_token down with the other {module} tests.
## 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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing - can these both be used in both the module_name and position fields? "Position Field Resolution" says only {module} can be there, but resolve_position function makes it sound like {module_name} can be their also? Can multiple {module} be in the position field?

If not the validators should be updated for position field and tests for it as well.


for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
# Validate {module_path} - can only appear once
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe move the token validator to a separate private function to make clearer.

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)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has common code with resolve_label, can just make a private function - something like:

    def _resolve_placeholders(self, text, module):
        """Resolve {module} and {module_path} placeholders in text (module must be set)."""
        positions = [m.module_bay.position for m in self._get_module_tree(module)]
        return resolve_module_placeholders(text, positions)

    def resolve_name(self, module):
        if not module:
            return self.name
        return self._resolve_placeholders(self.name, module)

    def resolve_label(self, module):
        if not module:
            return self.label
        return self._resolve_placeholders(self.label, module)

Fixes Issue #20467.
"""
if module:
positions = [m.module_bay.position for m in self._get_module_tree(module)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually this seems duplicated in the my suggestion above - can the position and resolve_module_placeholders just be made into a common function. Currently duplicated three times.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants