mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-10 10:57:43 +01:00
Compare commits
7 Commits
fix_module
...
21102-fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e111c769 | ||
|
|
c11f4b3716 | ||
|
|
3624b88c3f | ||
|
|
f54ed8bb7f | ||
|
|
5d0609e729 | ||
|
|
865b88e724 | ||
|
|
e73db97d46 |
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install -y gettext
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,7 +9,8 @@ yarn-error.log*
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/media
|
||||
/netbox/media/*
|
||||
!/netbox/media/.gitkeep
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -16,33 +16,9 @@ Note that device bays and module bays may _not_ be added to modules.
|
||||
|
||||
## Automatic Component Renaming
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
### `{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".
|
||||
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]`.
|
||||
|
||||
Automatic renaming is supported for all modular component types (those listed above).
|
||||
|
||||
|
||||
@@ -79,8 +79,6 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
MODULE_PATH_TOKEN = '{module_path}'
|
||||
MODULE_TOKEN_SEPARATOR = '/'
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.utils import resolve_module_placeholders
|
||||
from utilities.forms import get_field_value
|
||||
|
||||
__all__ = (
|
||||
@@ -120,47 +119,25 @@ class ModuleCommonForm(forms.Form):
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
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
|
||||
if has_module_token or has_module_path_token:
|
||||
if MODULE_TOKEN in template.name:
|
||||
if not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
|
||||
# Cannot mix {module} and {module_path} in the same attribute
|
||||
if has_module_token and has_module_path_token:
|
||||
if len(module_bays) != template.name.count(MODULE_TOKEN):
|
||||
raise forms.ValidationError(
|
||||
_("Cannot mix {module} and {module_path} placeholders in the same template attribute.")
|
||||
_(
|
||||
"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)
|
||||
)
|
||||
)
|
||||
|
||||
# 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(
|
||||
_(
|
||||
"Cannot install module with placeholder values in a module bay tree {level} deep "
|
||||
"but {tokens} placeholders given."
|
||||
).format(
|
||||
level=len(module_bays), tokens=token_count
|
||||
)
|
||||
)
|
||||
|
||||
# Use centralized helper for placeholder substitution
|
||||
positions = [mb.position for mb in module_bays]
|
||||
resolved_name = resolve_module_placeholders(resolved_name, positions)
|
||||
for module_bay in module_bays:
|
||||
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models.base import PortMappingBase
|
||||
from dcim.utils import resolve_module_placeholders
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@@ -171,17 +170,27 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
return modules
|
||||
|
||||
def resolve_name(self, module):
|
||||
"""Resolve {module} and {module_path} placeholders in component name."""
|
||||
if MODULE_TOKEN not in self.name:
|
||||
return self.name
|
||||
|
||||
if module:
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_placeholders(self.name, positions)
|
||||
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
|
||||
return self.name
|
||||
|
||||
def resolve_label(self, module):
|
||||
"""Resolve {module} and {module_path} placeholders in component label."""
|
||||
if MODULE_TOKEN not in self.label:
|
||||
return self.label
|
||||
|
||||
if module:
|
||||
positions = [m.module_bay.position for m in self._get_module_tree(module)]
|
||||
return resolve_module_placeholders(self.label, positions)
|
||||
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
|
||||
return self.label
|
||||
|
||||
|
||||
@@ -712,26 +721,11 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name = _('module bay template')
|
||||
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):
|
||||
module = kwargs.get('module')
|
||||
return self.component_model(
|
||||
name=self.resolve_name(module),
|
||||
label=self.resolve_label(module),
|
||||
position=self.resolve_position(module),
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
**kwargs
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
|
||||
@@ -259,11 +259,13 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
module_bays = []
|
||||
modules = []
|
||||
while module:
|
||||
if module.pk in modules or module.module_bay.pk in module_bays:
|
||||
module_module_bay = getattr(module, "module_bay", None)
|
||||
if module.pk in modules or (module_module_bay and module_module_bay.pk in module_bays):
|
||||
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
|
||||
modules.append(module.pk)
|
||||
module_bays.append(module.module_bay.pk)
|
||||
module = module.module_bay.module if module.module_bay else None
|
||||
if module_module_bay:
|
||||
module_bays.append(module_module_bay.pk)
|
||||
module = module_module_bay.module if module_module_bay else None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,54 +4,6 @@ from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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):
|
||||
return f'{ct_id}:{object_id}'
|
||||
|
||||
0
netbox/media/.gitkeep
Normal file
0
netbox/media/.gitkeep
Normal file
@@ -232,7 +232,7 @@ VPN_MENU = Menu(
|
||||
label=_('L2VPNs'),
|
||||
items=(
|
||||
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('Terminations')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('L2VPN Terminations')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important;padding-top:var(--px-16)}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{background:hsl(var(--color-base))!important;border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);color:hsl(var(--color-neutral))!important;margin:0 var(--px-8);padding:var(--px-4) var(--px-6)}.graphiql-operation-title-bar .toolbar-button{line-height:0;margin-left:var(--px-8);color:hsla(var(--color-neutral),var(--alpha-secondary, .6));font-size:var(--font-size-h3);vertical-align:middle}.graphiql-explorer-graphql-arguments input{line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{padding-top:var(--px-16);border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}.graphiql-explorer-root>div{overflow:auto!important}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);margin:0 var(--px-8);padding:var(--px-4)var(--px-6);background:hsl(var(--color-base))!important;color:hsl(var(--color-neutral))!important}.toolbar-button{all:unset;cursor:pointer;margin-left:var(--px-6);color:hsl(var(--color-primary));line-height:0!important;font-size:var(--font-size-h3)!important}.graphiql-explorer-slug .toolbar-button,.graphiql-explorer-graphql-arguments .toolbar-button{font-size:inherit!important}.graphiql-explorer-graphql-arguments input{min-width:2rem;line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@graphiql/plugin-explorer": "3.2.6",
|
||||
"@graphiql/plugin-explorer": "4.0.6",
|
||||
"graphiql": "4.1.2",
|
||||
"graphql": "16.12.0",
|
||||
"js-cookie": "3.0.5",
|
||||
|
||||
@@ -294,10 +294,10 @@
|
||||
react-compiler-runtime "19.1.0-rc.1"
|
||||
zustand "^5"
|
||||
|
||||
"@graphiql/plugin-explorer@3.2.6":
|
||||
version "3.2.6"
|
||||
resolved "https://registry.npmjs.org/@graphiql/plugin-explorer/-/plugin-explorer-3.2.6.tgz"
|
||||
integrity sha512-MXzG/zVNzZfes4Em253bHyAbD/lwwAZkPKvxCAQkjz0i3dtcv4uF3D8iqJ7214iu3SCphbORYZZUC93fik1yew==
|
||||
"@graphiql/plugin-explorer@4.0.6":
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-4.0.6.tgz#bec1207dc27334914590ab31f46c2e944bbf4ebf"
|
||||
integrity sha512-TppIi92YPER3v70nlF01KTQrq9AiYqkZicSd1hpU7aqGmbqw/pLwBNLUEcfENBoJtw574Qxjswb01+GaYK0Tzw==
|
||||
dependencies:
|
||||
graphiql-explorer "^0.9.0"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user