netbox-interface-name-rules 1.2.2__tar.gz → 1.3.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.
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/PKG-INFO +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/README.md +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/__init__.py +1 -1
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/engine.py +33 -15
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/filters.py +1 -1
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/models.py +42 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/signals.py +2 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tables.py +1 -1
- netbox_interface_name_rules-1.3.0/netbox_interface_name_rules/templates/netbox_interface_name_rules/buttons/export_yaml_only.html +33 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +0 -17
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_api.py +14 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_device_rules.py +46 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_engine.py +12 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_engine_advanced.py +120 -7
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_misc.py +71 -40
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_rules.py +24 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_signals.py +37 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_views.py +246 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/views.py +105 -59
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules.egg-info/PKG-INFO +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules.egg-info/SOURCES.txt +1 -1
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/pyproject.toml +2 -2
- netbox_interface_name_rules-1.2.2/netbox_interface_name_rules/utils.py +0 -17
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/LICENSE +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/api/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/api/serializers.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/api/urls.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/api/views.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/forms.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/jobs.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0002_regex_pattern_matching.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0006_alter_interfacenamerule_options.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0007_alter_optional_fks_set_null.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0008_constraint_nonempty_pattern.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0010_interfacenamerule_applies_to_device_interfaces.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0011_remove_interfacenamerule_interfacenamerule_module_type_mode_check_and_more.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/0012_remove_interfacenamerule_interfacenamerule_unique_exact_and_more.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/migrations/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/navigation.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/tests/test_regex.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules/urls.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-interface-name-rules
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: NetBox plugin for automatic interface renaming when modules are installed
|
|
5
5
|
Author: Marcin Zieba
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -23,10 +23,6 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
# NetBox Interface Name Rules Plugin
|
|
25
25
|
|
|
26
|
-
<p align="center">
|
|
27
|
-
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="80"/>
|
|
28
|
-
</p>
|
|
29
|
-
|
|
30
26
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
31
27
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
32
28
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/actions/workflows/test.yaml)
|
|
@@ -35,6 +31,7 @@ Dynamic: license-file
|
|
|
35
31
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
36
32
|
[](https://github.com/netbox-community/netbox)
|
|
37
33
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
|
|
34
|
+
[](https://api.reuse.software/info/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)
|
|
38
35
|
|
|
39
36
|
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
40
37
|
|
|
@@ -83,6 +80,9 @@ Rules are managed through the NetBox UI under **Plugins → Interface Name Rules
|
|
|
83
80
|
See the [full configuration guide](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/) for all rule fields, priority scoring, and template variable reference.
|
|
84
81
|
|
|
85
82
|
## Screenshots
|
|
83
|
+
<p align="center">
|
|
84
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="30"/>
|
|
85
|
+
</p>
|
|
86
86
|
|
|
87
87
|
<p align="center">
|
|
88
88
|
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/01-rule-list.png" alt="Rule list" width="700"/>
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# NetBox Interface Name Rules Plugin
|
|
2
2
|
|
|
3
|
-
<p align="center">
|
|
4
|
-
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="80"/>
|
|
5
|
-
</p>
|
|
6
|
-
|
|
7
3
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
8
4
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
9
5
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/actions/workflows/test.yaml)
|
|
@@ -12,6 +8,7 @@
|
|
|
12
8
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
13
9
|
[](https://github.com/netbox-community/netbox)
|
|
14
10
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
|
|
11
|
+
[](https://api.reuse.software/info/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)
|
|
15
12
|
|
|
16
13
|
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
17
14
|
|
|
@@ -60,6 +57,9 @@ Rules are managed through the NetBox UI under **Plugins → Interface Name Rules
|
|
|
60
57
|
See the [full configuration guide](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/) for all rule fields, priority scoring, and template variable reference.
|
|
61
58
|
|
|
62
59
|
## Screenshots
|
|
60
|
+
<p align="center">
|
|
61
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="30"/>
|
|
62
|
+
</p>
|
|
63
63
|
|
|
64
64
|
<p align="center">
|
|
65
65
|
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/01-rule-list.png" alt="Rule list" width="700"/>
|
|
@@ -86,4 +86,4 @@ Apache 2.0
|
|
|
86
86
|
|
|
87
87
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to submit code or interface name rules.
|
|
88
88
|
|
|
89
|
-
Community-contributed rules for various vendors are in the [`contrib/`](contrib/) directory.
|
|
89
|
+
Community-contributed rules for various vendors are in the [`contrib/`](contrib/) directory.
|
|
@@ -10,7 +10,8 @@ import ast
|
|
|
10
10
|
import logging
|
|
11
11
|
import re
|
|
12
12
|
|
|
13
|
-
from django.
|
|
13
|
+
from django.core.exceptions import ValidationError
|
|
14
|
+
from django.db import IntegrityError, transaction
|
|
14
15
|
from django.db.models.functions import Length
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
@@ -128,7 +129,7 @@ def _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks):
|
|
|
128
129
|
|
|
129
130
|
try:
|
|
130
131
|
new_name = evaluate_name_template(rule.name_template, variables)
|
|
131
|
-
except
|
|
132
|
+
except (ValueError, TypeError, re.error):
|
|
132
133
|
logger.exception(
|
|
133
134
|
"Failed to evaluate template %r for interface %s (rule %s)",
|
|
134
135
|
rule.name_template,
|
|
@@ -144,7 +145,7 @@ def _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks):
|
|
|
144
145
|
iface.name = new_name
|
|
145
146
|
try:
|
|
146
147
|
iface.full_clean()
|
|
147
|
-
except
|
|
148
|
+
except ValidationError:
|
|
148
149
|
logger.exception(
|
|
149
150
|
"Validation failed for device interface %s → %s (rule %s, device %s)",
|
|
150
151
|
old_name,
|
|
@@ -156,7 +157,7 @@ def _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks):
|
|
|
156
157
|
return False
|
|
157
158
|
try:
|
|
158
159
|
iface.save()
|
|
159
|
-
except
|
|
160
|
+
except (IntegrityError, ValidationError):
|
|
160
161
|
logger.exception(
|
|
161
162
|
"DB save failed for device interface %s → %s (rule %s, device %s)",
|
|
162
163
|
old_name,
|
|
@@ -401,7 +402,7 @@ def _resolve_bay_position(module_bay):
|
|
|
401
402
|
digits = _extract_trailing_digits(module_bay.name)
|
|
402
403
|
bay_position = digits if digits else "0"
|
|
403
404
|
digits = _extract_trailing_digits(bay_position)
|
|
404
|
-
bay_position_num = digits if digits else
|
|
405
|
+
bay_position_num = digits if digits else "0"
|
|
405
406
|
return bay_position, bay_position_num
|
|
406
407
|
|
|
407
408
|
|
|
@@ -524,6 +525,8 @@ def _find_channel_base(rule, ifaces, variables):
|
|
|
524
525
|
_apply_rule_to_interface exactly ONCE per module for channel rules, preventing
|
|
525
526
|
duplicate-name IntegrityErrors when channels already exist.
|
|
526
527
|
"""
|
|
528
|
+
if not ifaces:
|
|
529
|
+
return None
|
|
527
530
|
for iface in ifaces:
|
|
528
531
|
vars_copy = dict(variables)
|
|
529
532
|
vars_copy["base"] = iface.name
|
|
@@ -558,8 +561,7 @@ def has_applicable_interfaces(rule) -> bool:
|
|
|
558
561
|
Calls find_interfaces_for_rule(limit=1) to determine if any currently installed
|
|
559
562
|
interface would receive a new name. Returns False when:
|
|
560
563
|
- no matching modules/interfaces are installed, OR
|
|
561
|
-
- all matching interfaces are already correctly named
|
|
562
|
-
{module_path} at install time, making the rule a no-op for existing interfaces).
|
|
564
|
+
- all matching interfaces are already correctly named.
|
|
563
565
|
|
|
564
566
|
This is more expensive than a plain EXISTS query but ensures the Applicable
|
|
565
567
|
column in the Apply Rules list accurately reflects "would something change?"
|
|
@@ -568,7 +570,7 @@ def has_applicable_interfaces(rule) -> bool:
|
|
|
568
570
|
try:
|
|
569
571
|
results, _ = find_interfaces_for_rule(rule, limit=1)
|
|
570
572
|
return len(results) > 0
|
|
571
|
-
except
|
|
573
|
+
except (ValueError, re.error):
|
|
572
574
|
return False
|
|
573
575
|
|
|
574
576
|
|
|
@@ -608,6 +610,8 @@ def _evaluate_plain_interface(rule, module, iface, variables) -> dict | None:
|
|
|
608
610
|
def _channel_rule_entry(rule, module, ifaces, variables) -> dict | None:
|
|
609
611
|
"""Return a result dict if the channel rule would change any name for this module, else None."""
|
|
610
612
|
base_iface = _find_channel_base(rule, ifaces, variables)
|
|
613
|
+
if base_iface is None:
|
|
614
|
+
return None
|
|
611
615
|
vars_copy = {**variables, "base": base_iface.name}
|
|
612
616
|
expected_names = []
|
|
613
617
|
try:
|
|
@@ -680,6 +684,8 @@ def find_interfaces_for_rule(rule, limit=None):
|
|
|
680
684
|
If *limit* is set the list is truncated after that many changed entries, but
|
|
681
685
|
*total_checked* always reflects the full count of interfaces examined.
|
|
682
686
|
"""
|
|
687
|
+
from collections import defaultdict
|
|
688
|
+
|
|
683
689
|
from dcim.models import Interface
|
|
684
690
|
|
|
685
691
|
module_qs = _build_module_qs(rule).select_related(
|
|
@@ -693,13 +699,18 @@ def find_interfaces_for_rule(rule, limit=None):
|
|
|
693
699
|
)
|
|
694
700
|
process_fn = _process_channel_module if rule.channel_count > 0 else _process_plain_module
|
|
695
701
|
|
|
696
|
-
|
|
702
|
+
# Batch-load all interfaces for matching modules to avoid N+1 queries.
|
|
703
|
+
ifaces_by_module = defaultdict(list)
|
|
704
|
+
for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"):
|
|
705
|
+
ifaces_by_module[iface.module_id].append(iface)
|
|
706
|
+
|
|
707
|
+
processed_pks = set()
|
|
697
708
|
results = []
|
|
698
709
|
total_checked = 0
|
|
699
710
|
for module in module_qs:
|
|
700
|
-
processed_pks.
|
|
711
|
+
processed_pks.add(module.pk)
|
|
701
712
|
variables = build_variables(module.module_bay, device=module.device)
|
|
702
|
-
ifaces =
|
|
713
|
+
ifaces = ifaces_by_module.get(module.pk, [])
|
|
703
714
|
checked, stop = process_fn(rule, module, ifaces, variables, limit, results, module_qs, processed_pks)
|
|
704
715
|
total_checked += checked
|
|
705
716
|
if stop:
|
|
@@ -726,6 +737,8 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
726
737
|
|
|
727
738
|
Returns the number of interfaces renamed/created.
|
|
728
739
|
"""
|
|
740
|
+
from collections import defaultdict
|
|
741
|
+
|
|
729
742
|
from dcim.models import Interface
|
|
730
743
|
|
|
731
744
|
id_set = frozenset(interface_ids) if interface_ids is not None else None
|
|
@@ -737,15 +750,20 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
737
750
|
|
|
738
751
|
module_qs = _build_module_qs(rule)
|
|
739
752
|
|
|
753
|
+
# Batch-load interfaces to avoid N+1 queries in the module loop.
|
|
754
|
+
ifaces_by_module = defaultdict(list)
|
|
755
|
+
for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"):
|
|
756
|
+
ifaces_by_module[iface.module_id].append(iface)
|
|
757
|
+
|
|
740
758
|
count = 0
|
|
741
759
|
for module in module_qs.select_related("module_bay", "module_type", "device", "device__virtual_chassis"):
|
|
742
760
|
variables = build_variables(module.module_bay, device=module.device)
|
|
761
|
+
ifaces = ifaces_by_module.get(module.pk, [])
|
|
743
762
|
|
|
744
763
|
if rule.channel_count > 0:
|
|
745
764
|
# Channel rule: process module ONCE using the best base interface.
|
|
746
765
|
# Calling _apply_rule_to_interface for each existing interface would
|
|
747
766
|
# attempt to create the same channel names multiple times.
|
|
748
|
-
ifaces = list(Interface.objects.filter(module=module).order_by("name"))
|
|
749
767
|
if not ifaces:
|
|
750
768
|
continue
|
|
751
769
|
base_iface = _find_channel_base(rule, ifaces, variables)
|
|
@@ -755,7 +773,7 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
755
773
|
vars_copy["base"] = base_iface.name
|
|
756
774
|
try:
|
|
757
775
|
count += _apply_rule_to_interface(rule, base_iface, vars_copy, module)
|
|
758
|
-
except
|
|
776
|
+
except (ValueError, ValidationError, IntegrityError):
|
|
759
777
|
logger.exception(
|
|
760
778
|
"Failed to apply channel rule '%s' to module '%s' (id=%s); skipping.",
|
|
761
779
|
rule,
|
|
@@ -763,14 +781,14 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
763
781
|
module.pk,
|
|
764
782
|
)
|
|
765
783
|
else:
|
|
766
|
-
for iface in
|
|
784
|
+
for iface in ifaces:
|
|
767
785
|
if id_set is not None and iface.pk not in id_set:
|
|
768
786
|
continue
|
|
769
787
|
vars_copy = dict(variables)
|
|
770
788
|
vars_copy["base"] = iface.name
|
|
771
789
|
try:
|
|
772
790
|
count += _apply_rule_to_interface(rule, iface, vars_copy, module)
|
|
773
|
-
except
|
|
791
|
+
except (ValueError, ValidationError, IntegrityError):
|
|
774
792
|
logger.exception(
|
|
775
793
|
"Failed to apply rule '%s' to interface '%s' (id=%s); skipping.",
|
|
776
794
|
rule,
|
|
@@ -53,7 +53,7 @@ class InterfaceNameRuleFilterSet(NetBoxModelFilterSet):
|
|
|
53
53
|
|
|
54
54
|
def search(self, queryset, name, value):
|
|
55
55
|
"""Filter by pattern, template, description, or module type model name."""
|
|
56
|
-
return queryset.filter(
|
|
56
|
+
return queryset.select_related("module_type").filter(
|
|
57
57
|
Q(module_type_pattern__icontains=value)
|
|
58
58
|
| Q(name_template__icontains=value)
|
|
59
59
|
| Q(description__icontains=value)
|
|
@@ -283,3 +283,45 @@ class InterfaceNameRule(NetBoxModel):
|
|
|
283
283
|
device = f" on {self.device_type.model}" if self.device_type else ""
|
|
284
284
|
platform = f" [{self.platform.name}]" if self.platform else ""
|
|
285
285
|
return f"{module}{parent}{device}{platform} → {self.name_template}"
|
|
286
|
+
|
|
287
|
+
csv_headers = [
|
|
288
|
+
"module_type",
|
|
289
|
+
"module_type_pattern",
|
|
290
|
+
"module_type_is_regex",
|
|
291
|
+
"parent_module_type",
|
|
292
|
+
"device_type",
|
|
293
|
+
"platform",
|
|
294
|
+
"name_template",
|
|
295
|
+
"channel_count",
|
|
296
|
+
"channel_start",
|
|
297
|
+
"description",
|
|
298
|
+
"enabled",
|
|
299
|
+
"applies_to_device_interfaces",
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
def to_csv(self):
|
|
303
|
+
"""Return a tuple of field values for CSV export (matches csv_headers order)."""
|
|
304
|
+
return (
|
|
305
|
+
self.module_type.model if self.module_type else "",
|
|
306
|
+
self.module_type_pattern,
|
|
307
|
+
self.module_type_is_regex,
|
|
308
|
+
self.parent_module_type.model if self.parent_module_type else "",
|
|
309
|
+
self.device_type.model if self.device_type else "",
|
|
310
|
+
self.platform.name if self.platform else "",
|
|
311
|
+
self.name_template,
|
|
312
|
+
self.channel_count,
|
|
313
|
+
self.channel_start,
|
|
314
|
+
self.description,
|
|
315
|
+
self.enabled,
|
|
316
|
+
self.applies_to_device_interfaces,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def to_yaml(self):
|
|
320
|
+
"""Return a YAML document for this rule (used by NetBox's built-in Export)."""
|
|
321
|
+
import yaml
|
|
322
|
+
|
|
323
|
+
entry = {}
|
|
324
|
+
for header, value in zip(self.csv_headers, self.to_csv()):
|
|
325
|
+
if (value != "" and value is not None) or header in {"name_template"}:
|
|
326
|
+
entry[header] = value
|
|
327
|
+
return yaml.dump([entry], default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
@@ -37,6 +37,7 @@ def on_module_pre_save(sender, instance, **kwargs):
|
|
|
37
37
|
old = sender.objects.filter(pk=instance.pk).values("module_type_id").first()
|
|
38
38
|
instance._prev_module_type_id = old["module_type_id"] if old else None
|
|
39
39
|
except Exception:
|
|
40
|
+
logger.warning("Failed to capture previous module_type_id for Module pk=%s", instance.pk, exc_info=True)
|
|
40
41
|
instance._prev_module_type_id = None
|
|
41
42
|
|
|
42
43
|
|
|
@@ -141,6 +142,7 @@ def on_device_pre_save(sender, instance, **kwargs):
|
|
|
141
142
|
instance._prev_virtual_chassis_id = old["virtual_chassis_id"] if old else None
|
|
142
143
|
instance._prev_vc_position = old["vc_position"] if old else None
|
|
143
144
|
except Exception:
|
|
145
|
+
logger.warning("Failed to capture previous VC state for Device pk=%s", instance.pk, exc_info=True)
|
|
144
146
|
instance._prev_virtual_chassis_id = None
|
|
145
147
|
instance._prev_vc_position = None
|
|
146
148
|
|
|
@@ -96,7 +96,7 @@ class InterfaceNameRuleTable(NetBoxTable):
|
|
|
96
96
|
applies_to_device_interfaces = columns.BooleanColumn(verbose_name="Device Ifaces")
|
|
97
97
|
name_template = tables.Column(verbose_name="Name Template")
|
|
98
98
|
channel_count = tables.Column(verbose_name="Channels")
|
|
99
|
-
channel_start = tables.Column(verbose_name="
|
|
99
|
+
channel_start = tables.Column(verbose_name="Channel Start")
|
|
100
100
|
description = tables.Column(verbose_name="Description", linkify=False)
|
|
101
101
|
actions = columns.ActionsColumn(
|
|
102
102
|
actions=("edit", "delete"),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{# SPDX-License-Identifier: Apache-2.0 #}
|
|
2
|
+
{# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> #}
|
|
3
|
+
{% load i18n %}
|
|
4
|
+
<div class="dropdown">
|
|
5
|
+
<button type="button" class="btn btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
6
|
+
<i class="mdi mdi-download" aria-hidden="true"></i> {{ label }}
|
|
7
|
+
</button>
|
|
8
|
+
<ul class="dropdown-menu dropdown-menu-end">
|
|
9
|
+
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
|
|
10
|
+
{% if export_templates %}
|
|
11
|
+
<li>
|
|
12
|
+
<hr class="dropdown-divider">
|
|
13
|
+
</li>
|
|
14
|
+
{% for et in export_templates %}
|
|
15
|
+
<li>
|
|
16
|
+
<a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export={{ et.name|urlencode }}"
|
|
17
|
+
{% if et.description %} title="{{ et.description }}"{% endif %}
|
|
18
|
+
>
|
|
19
|
+
{{ et.name }}
|
|
20
|
+
</a>
|
|
21
|
+
</li>
|
|
22
|
+
{% endfor %}
|
|
23
|
+
{% endif %}
|
|
24
|
+
{% if perms.extras.add_exporttemplate %}
|
|
25
|
+
<li>
|
|
26
|
+
<hr class="dropdown-divider">
|
|
27
|
+
</li>
|
|
28
|
+
<li>
|
|
29
|
+
<a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?object_types={{ object_type.pk }}">{% trans "Add export template" %}...</a>
|
|
30
|
+
</li>
|
|
31
|
+
{% endif %}
|
|
32
|
+
</ul>
|
|
33
|
+
</div>
|
|
@@ -78,23 +78,6 @@
|
|
|
78
78
|
(device-level rule, pattern <code>Ethernet\d+/\d+</code>)</li>
|
|
79
79
|
</ul>
|
|
80
80
|
|
|
81
|
-
{% if supports_module_path %}
|
|
82
|
-
<div class="alert alert-success mt-3 mb-0">
|
|
83
|
-
<i class="mdi mdi-check-circle me-1"></i>
|
|
84
|
-
<strong><code>{module_path}</code> supported:</strong>
|
|
85
|
-
Platform naming rules (e.g., <code>et-0/0/{bay_position}</code>)
|
|
86
|
-
may be replaceable by using <code>{module_path}</code> in module type interface templates directly.
|
|
87
|
-
Converter offset and breakout rules are still needed.
|
|
88
|
-
</div>
|
|
89
|
-
{% else %}
|
|
90
|
-
<div class="alert alert-warning mt-3 mb-0">
|
|
91
|
-
<i class="mdi mdi-information-outline me-1"></i>
|
|
92
|
-
<strong><code>{module_path}</code> not available:</strong>
|
|
93
|
-
Platform naming rules
|
|
94
|
-
(e.g., Juniper <code>et-0/0/{bay_position}</code>) are required for correct interface naming.
|
|
95
|
-
Upgrade NetBox to get native <code>{module_path}</code> support.
|
|
96
|
-
</div>
|
|
97
|
-
{% endif %}
|
|
98
81
|
</div>
|
|
99
82
|
</div>
|
|
100
83
|
<script>
|
|
@@ -116,6 +116,20 @@ class InterfaceNameRuleAPITest(APITestCase):
|
|
|
116
116
|
response = self.client.post(url, data, format="json", **self.header)
|
|
117
117
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
118
118
|
|
|
119
|
+
def test_create_with_both_module_type_and_pattern_fails(self):
|
|
120
|
+
"""POST with both module_type FK and module_type_pattern set is rejected."""
|
|
121
|
+
url = self._get_list_url()
|
|
122
|
+
data = {
|
|
123
|
+
"module_type": self.module_type.pk,
|
|
124
|
+
"module_type_is_regex": True,
|
|
125
|
+
"module_type_pattern": "QSFP-.*",
|
|
126
|
+
"name_template": "port{bay_position}",
|
|
127
|
+
"channel_count": 0,
|
|
128
|
+
"channel_start": 0,
|
|
129
|
+
}
|
|
130
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
131
|
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
132
|
+
|
|
119
133
|
def test_update_rule_to_regex(self):
|
|
120
134
|
"""PATCH a rule to switch it to regex mode."""
|
|
121
135
|
rule = InterfaceNameRule.objects.create(
|
|
@@ -283,3 +283,49 @@ class ApplyDeviceInterfaceRulesModuleTypeTest(TestCase):
|
|
|
283
283
|
self.assertEqual(result, 1)
|
|
284
284
|
iface.refresh_from_db()
|
|
285
285
|
self.assertEqual(iface.name, "Gi2/3")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class DeviceInterfaceEdgeCaseNamesTest(TestCase):
|
|
289
|
+
"""Test _try_rename_device_interface with edge-case interface names."""
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def setUpTestData(cls):
|
|
293
|
+
mfg = Manufacturer.objects.create(name="EdgeMfg", slug="edgemfg")
|
|
294
|
+
cls.device_type = DeviceType.objects.create(manufacturer=mfg, model="Edge-Dev", slug="edge-dev")
|
|
295
|
+
role = DeviceRole.objects.create(name="EdgeRole", slug="edgerole")
|
|
296
|
+
site = Site.objects.create(name="EdgeSite", slug="edgesite")
|
|
297
|
+
vc = VirtualChassis.objects.create(name="edge-vc")
|
|
298
|
+
cls.device = Device.objects.create(
|
|
299
|
+
name="edge-sw1",
|
|
300
|
+
device_type=cls.device_type,
|
|
301
|
+
role=role,
|
|
302
|
+
site=site,
|
|
303
|
+
virtual_chassis=vc,
|
|
304
|
+
vc_position=1,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def test_name_with_multiple_slashes(self):
|
|
308
|
+
"""Interface name like FortyGigE1/2/3/4 extracts port='4'."""
|
|
309
|
+
InterfaceNameRule.objects.create(
|
|
310
|
+
applies_to_device_interfaces=True,
|
|
311
|
+
name_template="Fo{vc_position}/2/3/{port}",
|
|
312
|
+
)
|
|
313
|
+
iface = Interface.objects.create(
|
|
314
|
+
device=self.device, name="FortyGigE1/2/3/4", type="40gbase-x-qsfpp", module=None
|
|
315
|
+
)
|
|
316
|
+
result = apply_device_interface_rules(self.device)
|
|
317
|
+
self.assertEqual(result, 1)
|
|
318
|
+
iface.refresh_from_db()
|
|
319
|
+
self.assertEqual(iface.name, "Fo1/2/3/4")
|
|
320
|
+
|
|
321
|
+
def test_name_without_slash(self):
|
|
322
|
+
"""Interface name without / uses full name as port."""
|
|
323
|
+
InterfaceNameRule.objects.create(
|
|
324
|
+
applies_to_device_interfaces=True,
|
|
325
|
+
name_template="eth{vc_position}-{port}",
|
|
326
|
+
)
|
|
327
|
+
iface = Interface.objects.create(device=self.device, name="eth0", type="1000base-t", module=None)
|
|
328
|
+
result = apply_device_interface_rules(self.device)
|
|
329
|
+
self.assertEqual(result, 1)
|
|
330
|
+
iface.refresh_from_db()
|
|
331
|
+
self.assertEqual(iface.name, "eth1-eth0")
|
|
@@ -101,3 +101,15 @@ class EvaluateNameTemplateTest(TestCase):
|
|
|
101
101
|
{},
|
|
102
102
|
)
|
|
103
103
|
self.assertEqual(result, "port4")
|
|
104
|
+
|
|
105
|
+
def test_bay_position_num_non_numeric_uses_zero(self):
|
|
106
|
+
"""When bay_position has no trailing digits, bay_position_num falls back to '0'."""
|
|
107
|
+
from netbox_interface_name_rules.engine import _resolve_bay_position
|
|
108
|
+
|
|
109
|
+
class FakeBay:
|
|
110
|
+
position = "abc"
|
|
111
|
+
name = "Bay abc"
|
|
112
|
+
|
|
113
|
+
bay_position, bay_position_num = _resolve_bay_position(FakeBay())
|
|
114
|
+
self.assertEqual(bay_position, "abc")
|
|
115
|
+
self.assertEqual(bay_position_num, "0")
|