netbox-interface-name-rules 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. netbox_interface_name_rules-1.0.0/LICENSE +1 -0
  2. netbox_interface_name_rules-1.0.0/PKG-INFO +60 -0
  3. netbox_interface_name_rules-1.0.0/README.md +50 -0
  4. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/__init__.py +23 -0
  5. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/__init__.py +2 -0
  6. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/serializers.py +20 -0
  7. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/urls.py +12 -0
  8. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/views.py +12 -0
  9. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/engine.py +241 -0
  10. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/filters.py +11 -0
  11. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/forms.py +42 -0
  12. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/migrations/0001_initial.py +97 -0
  13. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/migrations/__init__.py +0 -0
  14. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/models.py +83 -0
  15. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/navigation.py +31 -0
  16. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/signals.py +76 -0
  17. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/tables.py +44 -0
  18. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/tests/__init__.py +2 -0
  19. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/urls.py +23 -0
  20. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/utils.py +19 -0
  21. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/views.py +58 -0
  22. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/PKG-INFO +60 -0
  23. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/SOURCES.txt +25 -0
  24. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/dependency_links.txt +1 -0
  25. netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/top_level.txt +1 -0
  26. netbox_interface_name_rules-1.0.0/pyproject.toml +55 -0
  27. netbox_interface_name_rules-1.0.0/setup.cfg +4 -0
@@ -0,0 +1 @@
1
+ Apache License 2.0
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-interface-name-rules
3
+ Version: 1.0.0
4
+ Summary: NetBox plugin for automatic interface renaming when modules are installed
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.12.0
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # NetBox Interface Name Rules Plugin
12
+
13
+ Automatic interface renaming when modules are installed into NetBox device bays.
14
+
15
+ ## What it does
16
+
17
+ When a module (transceiver, line card, converter) is installed into a module bay,
18
+ NetBox creates interfaces using position-based naming from the module type template.
19
+ This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
20
+
21
+ This plugin hooks into Django's `post_save` signal on the `Module` model to
22
+ automatically apply renaming rules based on configurable templates.
23
+
24
+ ## Features
25
+
26
+ - **Signal-driven** — rules fire automatically on module install, no manual step needed
27
+ - **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
28
+ - **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
29
+ - **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
30
+ - **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
31
+ - **Bulk import/export** — YAML-based rule management via the UI or API
32
+
33
+ ## Supported scenarios
34
+
35
+ | Scenario | Example |
36
+ |----------|---------|
37
+ | Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
38
+ | Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
39
+ | Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
40
+ | UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install netbox-interface-name-rules
46
+ ```
47
+
48
+ Add to `configuration.py`:
49
+ ```python
50
+ PLUGINS = ['netbox_interface_name_rules']
51
+ ```
52
+
53
+ ## Compatibility
54
+
55
+ - NetBox ≥ 4.2.0
56
+ - Python ≥ 3.12
57
+
58
+ ## License
59
+
60
+ Apache 2.0
@@ -0,0 +1,50 @@
1
+ # NetBox Interface Name Rules Plugin
2
+
3
+ Automatic interface renaming when modules are installed into NetBox device bays.
4
+
5
+ ## What it does
6
+
7
+ When a module (transceiver, line card, converter) is installed into a module bay,
8
+ NetBox creates interfaces using position-based naming from the module type template.
9
+ This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
10
+
11
+ This plugin hooks into Django's `post_save` signal on the `Module` model to
12
+ automatically apply renaming rules based on configurable templates.
13
+
14
+ ## Features
15
+
16
+ - **Signal-driven** — rules fire automatically on module install, no manual step needed
17
+ - **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
18
+ - **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
19
+ - **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
20
+ - **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
21
+ - **Bulk import/export** — YAML-based rule management via the UI or API
22
+
23
+ ## Supported scenarios
24
+
25
+ | Scenario | Example |
26
+ |----------|---------|
27
+ | Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
28
+ | Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
29
+ | Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
30
+ | UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install netbox-interface-name-rules
36
+ ```
37
+
38
+ Add to `configuration.py`:
39
+ ```python
40
+ PLUGINS = ['netbox_interface_name_rules']
41
+ ```
42
+
43
+ ## Compatibility
44
+
45
+ - NetBox ≥ 4.2.0
46
+ - Python ≥ 3.12
47
+
48
+ ## License
49
+
50
+ Apache 2.0
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.plugins import PluginConfig
4
+
5
+ __version__ = "1.0.0"
6
+
7
+
8
+ class InterfaceNameRulesConfig(PluginConfig):
9
+ name = "netbox_interface_name_rules"
10
+ verbose_name = "Interface Name Rules"
11
+ description = "Automatic interface renaming when modules are installed into bays."
12
+ version = __version__
13
+ base_url = "interface-name-rules"
14
+ min_version = "4.2.0"
15
+ required_settings = []
16
+ default_settings = {}
17
+
18
+ def ready(self):
19
+ super().ready()
20
+ from . import signals # noqa: F401 — registers post_save handler
21
+
22
+
23
+ config = InterfaceNameRulesConfig
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
@@ -0,0 +1,20 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.api.serializers import NetBoxModelSerializer
4
+
5
+ from netbox_interface_name_rules.models import InterfaceNameRule
6
+
7
+
8
+ class InterfaceNameRuleSerializer(NetBoxModelSerializer):
9
+ class Meta:
10
+ model = InterfaceNameRule
11
+ fields = [
12
+ "id",
13
+ "module_type",
14
+ "parent_module_type",
15
+ "device_type",
16
+ "name_template",
17
+ "channel_count",
18
+ "channel_start",
19
+ "description",
20
+ ]
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.api.routers import NetBoxRouter
4
+
5
+ from . import views
6
+
7
+ app_name = "netbox_interface_name_rules"
8
+
9
+ router = NetBoxRouter()
10
+ router.register("rules", views.InterfaceNameRuleViewSet)
11
+
12
+ urlpatterns = router.urls
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.api.viewsets import NetBoxModelViewSet
4
+
5
+ from netbox_interface_name_rules.models import InterfaceNameRule
6
+
7
+ from .serializers import InterfaceNameRuleSerializer
8
+
9
+
10
+ class InterfaceNameRuleViewSet(NetBoxModelViewSet):
11
+ queryset = InterfaceNameRule.objects.all()
12
+ serializer_class = InterfaceNameRuleSerializer
@@ -0,0 +1,241 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ """Core renaming engine — rule lookup and interface rename logic.
4
+
5
+ This module is imported lazily by signals.py so that model imports happen
6
+ after Django is fully initialised.
7
+ """
8
+
9
+ import ast
10
+ import re
11
+
12
+
13
+ def apply_interface_name_rules(module, module_bay):
14
+ """Apply InterfaceNameRule rename after module installation.
15
+
16
+ Looks up a matching rule for (module_type, parent_module_type, device_type)
17
+ and renames interfaces created by NetBox's template instantiation.
18
+
19
+ Only processes interfaces whose name still matches the raw bay position
20
+ (i.e., haven't been renamed yet), ensuring idempotency.
21
+
22
+ Returns:
23
+ Number of interfaces renamed/created, or 0 if no rule matched.
24
+ """
25
+ from dcim.models import Interface
26
+
27
+ module_type = module.module_type
28
+
29
+ # Determine parent module type (if installed inside another module)
30
+ parent_module_type = None
31
+ if module_bay.parent:
32
+ parent_bay = module_bay.parent
33
+ if hasattr(parent_bay, "installed_module") and parent_bay.installed_module:
34
+ parent_module_type = parent_bay.installed_module.module_type
35
+
36
+ # Look up rule: most specific first, then broader matches
37
+ device_type = module.device.device_type if module.device else None
38
+ rule = _find_matching_rule(module_type, parent_module_type, device_type)
39
+
40
+ if not rule:
41
+ return 0
42
+
43
+ variables = _build_variables(module_bay)
44
+ interfaces = list(Interface.objects.filter(module=module))
45
+
46
+ if not interfaces:
47
+ return 0
48
+
49
+ # Determine the raw interface name NetBox assigned from the template
50
+ # (the bay position). Only rename interfaces that still have this name.
51
+ raw_name = variables["bay_position"]
52
+ unrenamed = [i for i in interfaces if i.name == raw_name]
53
+ if not unrenamed:
54
+ return 0 # Already renamed (idempotent guard)
55
+
56
+ renamed = 0
57
+ for iface in unrenamed:
58
+ variables["base"] = iface.name
59
+ renamed += _apply_rule_to_interface(rule, iface, variables, module)
60
+
61
+ return renamed
62
+
63
+
64
+ def _find_matching_rule(module_type, parent_module_type, device_type):
65
+ """Find the most specific InterfaceNameRule matching the context.
66
+
67
+ Priority order:
68
+ 1. module_type + parent_module_type + device_type
69
+ 2. module_type + parent_module_type (any device)
70
+ 3. module_type + device_type (any parent)
71
+ 4. module_type only (universal)
72
+ """
73
+ from .models import InterfaceNameRule
74
+
75
+ candidates = [
76
+ (parent_module_type, device_type),
77
+ (parent_module_type, None),
78
+ (None, device_type),
79
+ (None, None),
80
+ ]
81
+
82
+ for pmt, dt in candidates:
83
+ if pmt is None and dt is None:
84
+ # Most generic — both nullable
85
+ rule = InterfaceNameRule.objects.filter(
86
+ module_type=module_type,
87
+ parent_module_type__isnull=True,
88
+ device_type__isnull=True,
89
+ ).first()
90
+ elif pmt is None:
91
+ rule = InterfaceNameRule.objects.filter(
92
+ module_type=module_type,
93
+ parent_module_type__isnull=True,
94
+ device_type=dt,
95
+ ).first()
96
+ elif dt is None:
97
+ rule = InterfaceNameRule.objects.filter(
98
+ module_type=module_type,
99
+ parent_module_type=pmt,
100
+ device_type__isnull=True,
101
+ ).first()
102
+ else:
103
+ rule = InterfaceNameRule.objects.filter(
104
+ module_type=module_type,
105
+ parent_module_type=pmt,
106
+ device_type=dt,
107
+ ).first()
108
+ if rule:
109
+ return rule
110
+
111
+ return None
112
+
113
+
114
+ def _build_variables(module_bay):
115
+ """Build template variable dict from module bay context."""
116
+ bay_position = module_bay.position or "0"
117
+ # If position is a template expression (e.g., {module}), extract from bay name
118
+ if bay_position.startswith("{"):
119
+ match = re.search(r"(\d+)$", module_bay.name)
120
+ bay_position = match.group(1) if match else "0"
121
+ # Extract numeric-only version for arithmetic (e.g., "swp1" → "1")
122
+ bay_position_num_match = re.search(r"(\d+)$", bay_position)
123
+ bay_position_num = bay_position_num_match.group(1) if bay_position_num_match else bay_position
124
+
125
+ parent_bay_position = "0"
126
+ sfp_slot = bay_position_num
127
+ slot = bay_position_num
128
+
129
+ if module_bay.parent:
130
+ parent_bay = module_bay.parent
131
+ parent_bay_position = parent_bay.position or "0"
132
+ # slot is typically the top-level module position
133
+ if parent_bay.parent and hasattr(parent_bay.parent, "installed_module"):
134
+ grandparent = parent_bay.parent
135
+ slot = grandparent.position or parent_bay_position
136
+ else:
137
+ slot = parent_bay_position
138
+ elif hasattr(module_bay, "module") and module_bay.module:
139
+ # Bay belongs to an installed module (not nested, but module-scoped)
140
+ owner_module = module_bay.module
141
+ if hasattr(owner_module, "module_bay") and owner_module.module_bay:
142
+ slot = owner_module.module_bay.position or bay_position
143
+
144
+ return {
145
+ "slot": slot,
146
+ "bay_position": bay_position,
147
+ "bay_position_num": bay_position_num,
148
+ "parent_bay_position": parent_bay_position,
149
+ "sfp_slot": sfp_slot,
150
+ }
151
+
152
+
153
+ def _apply_rule_to_interface(rule, iface, variables, module):
154
+ """Apply a single rule to an interface, handling breakout channels.
155
+
156
+ Returns number of interfaces renamed/created.
157
+ """
158
+ from dcim.models import Interface
159
+
160
+ count = 0
161
+
162
+ if rule.channel_count > 0:
163
+ # Breakout: rename base and create additional channel interfaces
164
+ for ch in range(rule.channel_count):
165
+ variables["channel"] = str(rule.channel_start + ch)
166
+ new_name = evaluate_name_template(rule.name_template, variables)
167
+ if ch == 0:
168
+ iface.name = new_name
169
+ iface.full_clean()
170
+ iface.save()
171
+ count += 1
172
+ else:
173
+ breakout_iface = Interface(
174
+ device=module.device,
175
+ module=module,
176
+ name=new_name,
177
+ type=iface.type,
178
+ enabled=iface.enabled,
179
+ )
180
+ breakout_iface.full_clean()
181
+ breakout_iface.save()
182
+ count += 1
183
+ else:
184
+ # Simple rename (converter offset, platform naming, etc.)
185
+ new_name = evaluate_name_template(rule.name_template, variables)
186
+ if new_name != iface.name:
187
+ iface.name = new_name
188
+ iface.full_clean()
189
+ iface.save()
190
+ count += 1
191
+
192
+ return count
193
+
194
+
195
+ def evaluate_name_template(template: str, variables: dict) -> str:
196
+ """Evaluate a name template with variable substitution and arithmetic.
197
+
198
+ Supports templates like:
199
+ "GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}"
200
+
201
+ Variables are substituted first, then any brace-enclosed expression
202
+ containing arithmetic operators is safely evaluated via AST.
203
+ """
204
+ # First pass: substitute all simple variables
205
+ result = template
206
+ for key, value in variables.items():
207
+ result = result.replace(f"{{{key}}}", str(value))
208
+
209
+ # Second pass: evaluate any remaining brace-enclosed arithmetic expressions
210
+ def _eval_expr(match):
211
+ expr = match.group(1).strip()
212
+ # Only allow digits, arithmetic operators, parentheses, and whitespace
213
+ if not re.match(r"^[\d\s\+\-\*\/\(\)]+$", expr):
214
+ raise ValueError(f"Unsafe expression in name template: {expr}")
215
+ try:
216
+ node = ast.parse(expr, mode="eval")
217
+ for child in ast.walk(node):
218
+ if not isinstance(
219
+ child,
220
+ (
221
+ ast.Expression,
222
+ ast.BinOp,
223
+ ast.UnaryOp,
224
+ ast.Constant,
225
+ ast.Add,
226
+ ast.Sub,
227
+ ast.Mult,
228
+ ast.Div,
229
+ ast.FloorDiv,
230
+ ast.Mod,
231
+ ast.USub,
232
+ ast.UAdd,
233
+ ),
234
+ ):
235
+ raise ValueError(f"Unsafe AST node in expression: {type(child).__name__}")
236
+ return str(eval(compile(node, "<template>", "eval"))) # noqa: S307
237
+ except (SyntaxError, TypeError) as e:
238
+ raise ValueError(f"Invalid arithmetic expression '{expr}': {e}") from e
239
+
240
+ result = re.sub(r"\{([^}]+)\}", _eval_expr, result)
241
+ return result
@@ -0,0 +1,11 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ import django_filters
4
+
5
+ from .models import InterfaceNameRule
6
+
7
+
8
+ class InterfaceNameRuleFilterSet(django_filters.FilterSet):
9
+ class Meta:
10
+ model = InterfaceNameRule
11
+ fields = ["module_type", "parent_module_type", "device_type"]
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from django import forms
4
+ from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
5
+
6
+ from .models import InterfaceNameRule
7
+
8
+
9
+ class InterfaceNameRuleForm(NetBoxModelForm):
10
+ class Meta:
11
+ model = InterfaceNameRule
12
+ fields = [
13
+ "module_type",
14
+ "parent_module_type",
15
+ "device_type",
16
+ "name_template",
17
+ "channel_count",
18
+ "channel_start",
19
+ "description",
20
+ ]
21
+
22
+
23
+ class InterfaceNameRuleImportForm(NetBoxModelImportForm):
24
+ class Meta:
25
+ model = InterfaceNameRule
26
+ fields = [
27
+ "module_type",
28
+ "parent_module_type",
29
+ "device_type",
30
+ "name_template",
31
+ "channel_count",
32
+ "channel_start",
33
+ "description",
34
+ ]
35
+
36
+
37
+ class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm):
38
+ module_type_id = forms.IntegerField(required=False, label="Module Type ID")
39
+ parent_module_type_id = forms.IntegerField(required=False, label="Parent Module Type ID")
40
+ device_type_id = forms.IntegerField(required=False, label="Parent Device Type ID")
41
+
42
+ model = InterfaceNameRule
@@ -0,0 +1,97 @@
1
+ import django.db.models.deletion
2
+ import netbox.models.deletion
3
+ import taggit.managers
4
+ import utilities.json
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ("dcim", "0225_gfk_indexes"),
13
+ ("extras", "0134_owner"),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name="InterfaceNameRule",
19
+ fields=[
20
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
21
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
22
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
23
+ (
24
+ "custom_field_data",
25
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
26
+ ),
27
+ (
28
+ "name_template",
29
+ models.CharField(
30
+ max_length=255,
31
+ help_text=(
32
+ "Interface name template expression, e.g. "
33
+ "'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
34
+ ),
35
+ ),
36
+ ),
37
+ (
38
+ "channel_count",
39
+ models.PositiveSmallIntegerField(
40
+ default=0,
41
+ help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
42
+ ),
43
+ ),
44
+ (
45
+ "channel_start",
46
+ models.PositiveSmallIntegerField(
47
+ default=0,
48
+ help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper, 1 for Cisco)",
49
+ ),
50
+ ),
51
+ (
52
+ "description",
53
+ models.TextField(blank=True, help_text="Optional description or notes about this rule"),
54
+ ),
55
+ (
56
+ "module_type",
57
+ models.ForeignKey(
58
+ on_delete=django.db.models.deletion.CASCADE,
59
+ related_name="+",
60
+ to="dcim.moduletype",
61
+ help_text="The module type whose installation triggers this rename rule",
62
+ ),
63
+ ),
64
+ (
65
+ "parent_module_type",
66
+ models.ForeignKey(
67
+ blank=True,
68
+ null=True,
69
+ on_delete=django.db.models.deletion.CASCADE,
70
+ related_name="+",
71
+ to="dcim.moduletype",
72
+ help_text="If set, rule only applies when installed inside this parent module type",
73
+ ),
74
+ ),
75
+ (
76
+ "device_type",
77
+ models.ForeignKey(
78
+ blank=True,
79
+ null=True,
80
+ on_delete=django.db.models.deletion.CASCADE,
81
+ related_name="+",
82
+ to="dcim.devicetype",
83
+ help_text="If set, rule only applies to devices of this parent device type",
84
+ ),
85
+ ),
86
+ (
87
+ "tags",
88
+ taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag", related_name="+"),
89
+ ),
90
+ ],
91
+ options={
92
+ "ordering": ["module_type__model"],
93
+ "unique_together": {("module_type", "parent_module_type", "device_type")},
94
+ },
95
+ bases=(netbox.models.deletion.DeleteMixin, models.Model),
96
+ ),
97
+ ]
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from dcim.models import DeviceType, ModuleType
4
+ from django.db import models
5
+ from django.urls import reverse
6
+ from netbox.models import NetBoxModel
7
+ from taggit.managers import TaggableManager
8
+
9
+
10
+ class InterfaceNameRule(NetBoxModel):
11
+ """Post-install interface rename rule for module types.
12
+
13
+ Handles cases where NetBox's position-based naming can't produce
14
+ the correct interface name, such as converter offset (CVR-X2-SFP)
15
+ or breakout transceivers (QSFP+ 4x10G).
16
+
17
+ The name_template uses Python str.format() syntax with these variables:
18
+ {slot} - Slot number from parent module bay position
19
+ {bay_position} - Position of the bay this module is installed into
20
+ {bay_position_num} - Numeric suffix of bay position (e.g., "swp1" → "1")
21
+ {parent_bay_position} - Position of the parent module's bay
22
+ {sfp_slot} - Sub-bay index within the parent module
23
+ {base} - Base interface name from NetBox position resolution
24
+ {channel} - Channel number (iterated for breakout)
25
+ """
26
+
27
+ module_type = models.ForeignKey(
28
+ ModuleType,
29
+ on_delete=models.CASCADE,
30
+ related_name="+",
31
+ help_text="The module type whose installation triggers this rename rule",
32
+ )
33
+ parent_module_type = models.ForeignKey(
34
+ ModuleType,
35
+ on_delete=models.CASCADE,
36
+ null=True,
37
+ blank=True,
38
+ related_name="+",
39
+ help_text="If set, rule only applies when installed inside this parent module type",
40
+ )
41
+ device_type = models.ForeignKey(
42
+ DeviceType,
43
+ on_delete=models.CASCADE,
44
+ null=True,
45
+ blank=True,
46
+ related_name="+",
47
+ help_text="If set, rule only applies to devices of this parent device type",
48
+ )
49
+ name_template = models.CharField(
50
+ max_length=255,
51
+ help_text=(
52
+ "Interface name template expression, e.g. "
53
+ "'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
54
+ ),
55
+ )
56
+ channel_count = models.PositiveSmallIntegerField(
57
+ default=0,
58
+ help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
59
+ )
60
+ channel_start = models.PositiveSmallIntegerField(
61
+ default=0,
62
+ help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper, 1 for Cisco)",
63
+ )
64
+ description = models.TextField(
65
+ blank=True,
66
+ help_text="Optional description or notes about this rule",
67
+ )
68
+
69
+ # Override inherited tags to avoid reverse accessor clash when co-installed
70
+ # with another plugin that has a model of the same name.
71
+ tags = TaggableManager(through="extras.TaggedItem", related_name="+")
72
+
73
+ def get_absolute_url(self):
74
+ return reverse("plugins:netbox_interface_name_rules:interfacenamerule_detail", args=[self.pk])
75
+
76
+ class Meta:
77
+ unique_together = ["module_type", "parent_module_type", "device_type"]
78
+ ordering = ["module_type__model"]
79
+
80
+ def __str__(self):
81
+ parent = f" in {self.parent_module_type.model}" if self.parent_module_type else ""
82
+ device = f" on {self.device_type.model}" if self.device_type else ""
83
+ return f"{self.module_type.model}{parent}{device} → {self.name_template}"
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
4
+
5
+ menu = PluginMenu(
6
+ label="Interface Name Rules",
7
+ icon_class="mdi mdi-swap-horizontal",
8
+ groups=(
9
+ (
10
+ "Rules",
11
+ (
12
+ PluginMenuItem(
13
+ link="plugins:netbox_interface_name_rules:interfacenamerule_list",
14
+ link_text="Interface Name Rules",
15
+ buttons=(
16
+ PluginMenuButton(
17
+ link="plugins:netbox_interface_name_rules:interfacenamerule_add",
18
+ title="Add",
19
+ icon_class="mdi mdi-plus-thick",
20
+ ),
21
+ PluginMenuButton(
22
+ link="plugins:netbox_interface_name_rules:interfacenamerule_bulk_import",
23
+ title="Import",
24
+ icon_class="mdi mdi-upload",
25
+ ),
26
+ ),
27
+ ),
28
+ ),
29
+ ),
30
+ ),
31
+ )
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ """Django signals for automatic interface renaming on module install."""
4
+
5
+ import functools
6
+ import logging
7
+ import threading
8
+
9
+ from django.db import connection, transaction
10
+ from django.db.models.signals import post_save
11
+ from django.dispatch import receiver
12
+
13
+ logger = logging.getLogger("netbox_interface_name_rules")
14
+
15
+ # Delay for auto-commit mode (seconds). Allows Module.save() to finish
16
+ # creating component instances before the rename logic runs.
17
+ _AUTOCOMMIT_DELAY = 0.5
18
+
19
+
20
+ @receiver(post_save, sender="dcim.Module", dispatch_uid="interface_name_rules_post_save_module")
21
+ def on_module_saved(sender, instance, created, **kwargs):
22
+ """Apply interface name rules when a module is installed (created).
23
+
24
+ NetBox creates module component instances (interfaces) in Module.save()
25
+ *after* super().save() returns — the point at which post_save fires.
26
+
27
+ In an explicit transaction (UI/API with atomic block),
28
+ transaction.on_commit() correctly defers execution until after the
29
+ full save completes, including interface creation.
30
+
31
+ In auto-commit mode, on_commit() fires immediately — a short timer
32
+ is used as a fallback to allow Module.save() to finish.
33
+ """
34
+ if not created:
35
+ return
36
+
37
+ module = instance
38
+ module_bay = getattr(module, "module_bay", None)
39
+ if not module_bay:
40
+ return
41
+
42
+ callback = functools.partial(_apply_rules_deferred, module.pk, module_bay.pk)
43
+
44
+ if connection.in_atomic_block:
45
+ transaction.on_commit(callback)
46
+ else:
47
+ threading.Timer(_AUTOCOMMIT_DELAY, callback).start()
48
+
49
+
50
+ def _apply_rules_deferred(module_pk, module_bay_pk):
51
+ """Apply interface name rules after transaction commit."""
52
+ from dcim.models import Module, ModuleBay
53
+
54
+ try:
55
+ module = Module.objects.get(pk=module_pk)
56
+ module_bay = ModuleBay.objects.get(pk=module_bay_pk)
57
+ except (Module.DoesNotExist, ModuleBay.DoesNotExist):
58
+ return
59
+
60
+ try:
61
+ from .engine import apply_interface_name_rules
62
+
63
+ renamed = apply_interface_name_rules(module, module_bay)
64
+ if renamed:
65
+ logger.info(
66
+ "Renamed %d interface(s) for %s in %s",
67
+ renamed,
68
+ module.module_type,
69
+ module_bay.name,
70
+ )
71
+ except Exception:
72
+ logger.exception(
73
+ "Failed to apply interface name rules for %s in %s",
74
+ module.module_type,
75
+ module_bay.name,
76
+ )
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ import django_tables2 as tables
4
+ from netbox.tables import NetBoxTable, columns
5
+
6
+ from .models import InterfaceNameRule
7
+
8
+
9
+ class InterfaceNameRuleTable(NetBoxTable):
10
+ pk = columns.ToggleColumn()
11
+ module_type = tables.Column(verbose_name="Module Type", linkify=True)
12
+ parent_module_type = tables.Column(verbose_name="Parent Module Type", linkify=True)
13
+ device_type = tables.Column(verbose_name="Parent Device Type", linkify=True)
14
+ name_template = tables.Column(verbose_name="Name Template")
15
+ channel_count = tables.Column(verbose_name="Channels")
16
+ channel_start = tables.Column(verbose_name="Ch. Start")
17
+ description = tables.Column(verbose_name="Description", linkify=False)
18
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
19
+
20
+ class Meta:
21
+ model = InterfaceNameRule
22
+ fields = (
23
+ "pk",
24
+ "id",
25
+ "module_type",
26
+ "parent_module_type",
27
+ "device_type",
28
+ "name_template",
29
+ "channel_count",
30
+ "channel_start",
31
+ "description",
32
+ "actions",
33
+ )
34
+ default_columns = (
35
+ "pk",
36
+ "module_type",
37
+ "parent_module_type",
38
+ "device_type",
39
+ "name_template",
40
+ "channel_count",
41
+ "description",
42
+ "actions",
43
+ )
44
+ attrs = {"class": "table table-hover table-headings table-striped"}
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from django.urls import path
4
+
5
+ from . import views
6
+
7
+ app_name = "netbox_interface_name_rules"
8
+
9
+ urlpatterns = [
10
+ # List / CRUD
11
+ path("rules/", views.InterfaceNameRuleListView.as_view(), name="interfacenamerule_list"),
12
+ path("rules/add/", views.InterfaceNameRuleCreateView.as_view(), name="interfacenamerule_add"),
13
+ path("rules/import/", views.InterfaceNameRuleBulkImportView.as_view(), name="interfacenamerule_bulk_import"),
14
+ path("rules/bulk_delete/", views.InterfaceNameRuleBulkDeleteView.as_view(), name="interfacenamerule_bulk_delete"),
15
+ path("rules/<int:pk>/", views.InterfaceNameRuleView.as_view(), name="interfacenamerule_detail"),
16
+ path("rules/<int:pk>/edit/", views.InterfaceNameRuleEditView.as_view(), name="interfacenamerule_edit"),
17
+ path("rules/<int:pk>/delete/", views.InterfaceNameRuleDeleteView.as_view(), name="interfacenamerule_delete"),
18
+ path(
19
+ "rules/<int:pk>/changelog/",
20
+ views.InterfaceNameRuleChangeLogView.as_view(),
21
+ name="interfacenamerule_changelog",
22
+ ),
23
+ ]
@@ -0,0 +1,19 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ """Version-detection utilities for the interface name rules plugin."""
4
+
5
+ MODULE_PATH_MIN_VERSION = "4.9.0"
6
+
7
+
8
+ def supports_module_path():
9
+ """Check if the running NetBox version supports the {module_path} template token."""
10
+ from django.conf import settings
11
+
12
+ version_str = getattr(settings, "VERSION", "0.0.0")
13
+ version_str = version_str.split("-")[0]
14
+ try:
15
+ current = tuple(int(x) for x in version_str.split("."))
16
+ required = tuple(int(x) for x in MODULE_PATH_MIN_VERSION.split("."))
17
+ return current >= required
18
+ except (ValueError, TypeError):
19
+ return False
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ from netbox.views import generic
4
+ from utilities.views import register_model_view
5
+
6
+ from .filters import InterfaceNameRuleFilterSet
7
+ from .forms import InterfaceNameRuleFilterForm, InterfaceNameRuleForm, InterfaceNameRuleImportForm
8
+ from .models import InterfaceNameRule
9
+ from .tables import InterfaceNameRuleTable
10
+
11
+
12
+ class InterfaceNameRuleListView(generic.ObjectListView):
13
+ queryset = InterfaceNameRule.objects.all()
14
+ table = InterfaceNameRuleTable
15
+ filterset = InterfaceNameRuleFilterSet
16
+ filterset_form = InterfaceNameRuleFilterForm
17
+ template_name = "netbox_interface_name_rules/interfacenamerule_list.html"
18
+
19
+ def get_extra_context(self, request):
20
+ from .utils import MODULE_PATH_MIN_VERSION, supports_module_path
21
+
22
+ return {
23
+ "supports_module_path": supports_module_path(),
24
+ "module_path_min_version": MODULE_PATH_MIN_VERSION,
25
+ }
26
+
27
+
28
+ class InterfaceNameRuleCreateView(generic.ObjectEditView):
29
+ queryset = InterfaceNameRule.objects.all()
30
+ form = InterfaceNameRuleForm
31
+
32
+
33
+ @register_model_view(InterfaceNameRule, "bulk_import", path="import", detail=False)
34
+ class InterfaceNameRuleBulkImportView(generic.BulkImportView):
35
+ queryset = InterfaceNameRule.objects.all()
36
+ model_form = InterfaceNameRuleImportForm
37
+
38
+
39
+ class InterfaceNameRuleView(generic.ObjectView):
40
+ queryset = InterfaceNameRule.objects.all()
41
+
42
+
43
+ class InterfaceNameRuleEditView(generic.ObjectEditView):
44
+ queryset = InterfaceNameRule.objects.all()
45
+ form = InterfaceNameRuleForm
46
+
47
+
48
+ class InterfaceNameRuleDeleteView(generic.ObjectDeleteView):
49
+ queryset = InterfaceNameRule.objects.all()
50
+
51
+
52
+ class InterfaceNameRuleBulkDeleteView(generic.BulkDeleteView):
53
+ queryset = InterfaceNameRule.objects.all()
54
+ table = InterfaceNameRuleTable
55
+
56
+
57
+ class InterfaceNameRuleChangeLogView(generic.ObjectChangeLogView):
58
+ queryset = InterfaceNameRule.objects.all()
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-interface-name-rules
3
+ Version: 1.0.0
4
+ Summary: NetBox plugin for automatic interface renaming when modules are installed
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.12.0
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # NetBox Interface Name Rules Plugin
12
+
13
+ Automatic interface renaming when modules are installed into NetBox device bays.
14
+
15
+ ## What it does
16
+
17
+ When a module (transceiver, line card, converter) is installed into a module bay,
18
+ NetBox creates interfaces using position-based naming from the module type template.
19
+ This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
20
+
21
+ This plugin hooks into Django's `post_save` signal on the `Module` model to
22
+ automatically apply renaming rules based on configurable templates.
23
+
24
+ ## Features
25
+
26
+ - **Signal-driven** — rules fire automatically on module install, no manual step needed
27
+ - **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
28
+ - **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
29
+ - **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
30
+ - **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
31
+ - **Bulk import/export** — YAML-based rule management via the UI or API
32
+
33
+ ## Supported scenarios
34
+
35
+ | Scenario | Example |
36
+ |----------|---------|
37
+ | Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
38
+ | Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
39
+ | Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
40
+ | UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install netbox-interface-name-rules
46
+ ```
47
+
48
+ Add to `configuration.py`:
49
+ ```python
50
+ PLUGINS = ['netbox_interface_name_rules']
51
+ ```
52
+
53
+ ## Compatibility
54
+
55
+ - NetBox ≥ 4.2.0
56
+ - Python ≥ 3.12
57
+
58
+ ## License
59
+
60
+ Apache 2.0
@@ -0,0 +1,25 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ netbox_interface_name_rules/__init__.py
5
+ netbox_interface_name_rules/engine.py
6
+ netbox_interface_name_rules/filters.py
7
+ netbox_interface_name_rules/forms.py
8
+ netbox_interface_name_rules/models.py
9
+ netbox_interface_name_rules/navigation.py
10
+ netbox_interface_name_rules/signals.py
11
+ netbox_interface_name_rules/tables.py
12
+ netbox_interface_name_rules/urls.py
13
+ netbox_interface_name_rules/utils.py
14
+ netbox_interface_name_rules/views.py
15
+ netbox_interface_name_rules.egg-info/PKG-INFO
16
+ netbox_interface_name_rules.egg-info/SOURCES.txt
17
+ netbox_interface_name_rules.egg-info/dependency_links.txt
18
+ netbox_interface_name_rules.egg-info/top_level.txt
19
+ netbox_interface_name_rules/api/__init__.py
20
+ netbox_interface_name_rules/api/serializers.py
21
+ netbox_interface_name_rules/api/urls.py
22
+ netbox_interface_name_rules/api/views.py
23
+ netbox_interface_name_rules/migrations/0001_initial.py
24
+ netbox_interface_name_rules/migrations/__init__.py
25
+ netbox_interface_name_rules/tests/__init__.py
@@ -0,0 +1 @@
1
+ netbox_interface_name_rules
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ [build-system]
4
+ requires = ["setuptools"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "netbox-interface-name-rules"
9
+ version = "1.0.0"
10
+ description = "NetBox plugin for automatic interface renaming when modules are installed"
11
+ readme = "README.md"
12
+ requires-python = ">=3.12.0"
13
+ license = "Apache-2.0"
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["netbox_interface_name_rules*"]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "build",
21
+ "reuse",
22
+ "ruff",
23
+ "pre-commit",
24
+ "pytest",
25
+ "pytest-django",
26
+ "python-semantic-release",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["/opt/netbox/netbox"]
31
+
32
+ [tool.ruff]
33
+ line-length = 120
34
+
35
+ [tool.ruff.lint]
36
+ ignore = ["E501", "F403", "F405"]
37
+
38
+ [tool.ruff.lint.per-file-ignores]
39
+ "__init__.py" = ["F401"]
40
+
41
+ [tool.semantic_release]
42
+ version_toml = ["pyproject.toml:project.version"]
43
+ version_variables = ["netbox_interface_name_rules/__init__.py:__version__"]
44
+ branch = "main"
45
+ commit_message = "chore(release): {version}\n\nAutomatically generated by python-semantic-release"
46
+ build_command = "python -m build"
47
+ upload_to_vcs_release = true
48
+
49
+ [tool.semantic_release.changelog]
50
+ changelog_file = "CHANGELOG.md"
51
+
52
+ [tool.semantic_release.commit_parser_options]
53
+ allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]
54
+ minor_tags = ["feat"]
55
+ patch_tags = ["fix", "perf"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+