netbox-interface-name-rules 1.2.2__tar.gz → 1.2.3__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.2.3}/PKG-INFO +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/README.md +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/__init__.py +1 -1
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/engine.py +32 -13
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/filters.py +1 -1
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/signals.py +2 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_api.py +14 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_device_rules.py +46 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine.py +12 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine_advanced.py +120 -7
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_rules.py +24 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_signals.py +37 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_views.py +48 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/views.py +14 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/PKG-INFO +5 -5
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/pyproject.toml +2 -2
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/LICENSE +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/serializers.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/urls.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/views.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/forms.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/jobs.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0002_regex_pattern_matching.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0006_alter_interfacenamerule_options.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/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.2.3}/netbox_interface_name_rules/migrations/0008_constraint_nonempty_pattern.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/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.2.3}/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.2.3}/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.2.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/models.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/navigation.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tables.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/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.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/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.2.3}/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.2.3}/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.2.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_misc.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_regex.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/urls.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/utils.py +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
- {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-interface-name-rules
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
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
|
|
@@ -568,7 +571,7 @@ def has_applicable_interfaces(rule) -> bool:
|
|
|
568
571
|
try:
|
|
569
572
|
results, _ = find_interfaces_for_rule(rule, limit=1)
|
|
570
573
|
return len(results) > 0
|
|
571
|
-
except
|
|
574
|
+
except (ValueError, re.error):
|
|
572
575
|
return False
|
|
573
576
|
|
|
574
577
|
|
|
@@ -608,6 +611,8 @@ def _evaluate_plain_interface(rule, module, iface, variables) -> dict | None:
|
|
|
608
611
|
def _channel_rule_entry(rule, module, ifaces, variables) -> dict | None:
|
|
609
612
|
"""Return a result dict if the channel rule would change any name for this module, else None."""
|
|
610
613
|
base_iface = _find_channel_base(rule, ifaces, variables)
|
|
614
|
+
if base_iface is None:
|
|
615
|
+
return None
|
|
611
616
|
vars_copy = {**variables, "base": base_iface.name}
|
|
612
617
|
expected_names = []
|
|
613
618
|
try:
|
|
@@ -680,6 +685,8 @@ def find_interfaces_for_rule(rule, limit=None):
|
|
|
680
685
|
If *limit* is set the list is truncated after that many changed entries, but
|
|
681
686
|
*total_checked* always reflects the full count of interfaces examined.
|
|
682
687
|
"""
|
|
688
|
+
from collections import defaultdict
|
|
689
|
+
|
|
683
690
|
from dcim.models import Interface
|
|
684
691
|
|
|
685
692
|
module_qs = _build_module_qs(rule).select_related(
|
|
@@ -693,13 +700,18 @@ def find_interfaces_for_rule(rule, limit=None):
|
|
|
693
700
|
)
|
|
694
701
|
process_fn = _process_channel_module if rule.channel_count > 0 else _process_plain_module
|
|
695
702
|
|
|
696
|
-
|
|
703
|
+
# Batch-load all interfaces for matching modules to avoid N+1 queries.
|
|
704
|
+
ifaces_by_module = defaultdict(list)
|
|
705
|
+
for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"):
|
|
706
|
+
ifaces_by_module[iface.module_id].append(iface)
|
|
707
|
+
|
|
708
|
+
processed_pks = set()
|
|
697
709
|
results = []
|
|
698
710
|
total_checked = 0
|
|
699
711
|
for module in module_qs:
|
|
700
|
-
processed_pks.
|
|
712
|
+
processed_pks.add(module.pk)
|
|
701
713
|
variables = build_variables(module.module_bay, device=module.device)
|
|
702
|
-
ifaces =
|
|
714
|
+
ifaces = ifaces_by_module.get(module.pk, [])
|
|
703
715
|
checked, stop = process_fn(rule, module, ifaces, variables, limit, results, module_qs, processed_pks)
|
|
704
716
|
total_checked += checked
|
|
705
717
|
if stop:
|
|
@@ -726,6 +738,8 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
726
738
|
|
|
727
739
|
Returns the number of interfaces renamed/created.
|
|
728
740
|
"""
|
|
741
|
+
from collections import defaultdict
|
|
742
|
+
|
|
729
743
|
from dcim.models import Interface
|
|
730
744
|
|
|
731
745
|
id_set = frozenset(interface_ids) if interface_ids is not None else None
|
|
@@ -737,15 +751,20 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
737
751
|
|
|
738
752
|
module_qs = _build_module_qs(rule)
|
|
739
753
|
|
|
754
|
+
# Batch-load interfaces to avoid N+1 queries in the module loop.
|
|
755
|
+
ifaces_by_module = defaultdict(list)
|
|
756
|
+
for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"):
|
|
757
|
+
ifaces_by_module[iface.module_id].append(iface)
|
|
758
|
+
|
|
740
759
|
count = 0
|
|
741
760
|
for module in module_qs.select_related("module_bay", "module_type", "device", "device__virtual_chassis"):
|
|
742
761
|
variables = build_variables(module.module_bay, device=module.device)
|
|
762
|
+
ifaces = ifaces_by_module.get(module.pk, [])
|
|
743
763
|
|
|
744
764
|
if rule.channel_count > 0:
|
|
745
765
|
# Channel rule: process module ONCE using the best base interface.
|
|
746
766
|
# Calling _apply_rule_to_interface for each existing interface would
|
|
747
767
|
# attempt to create the same channel names multiple times.
|
|
748
|
-
ifaces = list(Interface.objects.filter(module=module).order_by("name"))
|
|
749
768
|
if not ifaces:
|
|
750
769
|
continue
|
|
751
770
|
base_iface = _find_channel_base(rule, ifaces, variables)
|
|
@@ -755,7 +774,7 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
755
774
|
vars_copy["base"] = base_iface.name
|
|
756
775
|
try:
|
|
757
776
|
count += _apply_rule_to_interface(rule, base_iface, vars_copy, module)
|
|
758
|
-
except
|
|
777
|
+
except (ValueError, ValidationError, IntegrityError):
|
|
759
778
|
logger.exception(
|
|
760
779
|
"Failed to apply channel rule '%s' to module '%s' (id=%s); skipping.",
|
|
761
780
|
rule,
|
|
@@ -763,14 +782,14 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None):
|
|
|
763
782
|
module.pk,
|
|
764
783
|
)
|
|
765
784
|
else:
|
|
766
|
-
for iface in
|
|
785
|
+
for iface in ifaces:
|
|
767
786
|
if id_set is not None and iface.pk not in id_set:
|
|
768
787
|
continue
|
|
769
788
|
vars_copy = dict(variables)
|
|
770
789
|
vars_copy["base"] = iface.name
|
|
771
790
|
try:
|
|
772
791
|
count += _apply_rule_to_interface(rule, iface, vars_copy, module)
|
|
773
|
-
except
|
|
792
|
+
except (ValueError, ValidationError, IntegrityError):
|
|
774
793
|
logger.exception(
|
|
775
794
|
"Failed to apply rule '%s' to interface '%s' (id=%s); skipping.",
|
|
776
795
|
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)
|
|
@@ -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
|
|
|
@@ -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")
|
|
@@ -19,7 +19,6 @@ from dcim.models import (
|
|
|
19
19
|
Site,
|
|
20
20
|
VirtualChassis,
|
|
21
21
|
)
|
|
22
|
-
from django.db import DatabaseError
|
|
23
22
|
from django.test import TestCase
|
|
24
23
|
|
|
25
24
|
from netbox_interface_name_rules.engine import (
|
|
@@ -265,9 +264,12 @@ class ApplyRuleToExistingTest(EngineAdvancedFixtures):
|
|
|
265
264
|
enabled=False,
|
|
266
265
|
)
|
|
267
266
|
module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
|
|
268
|
-
Interface.objects.create(device=self.device, module=module, name="0", type="10gbase-x-sfpp")
|
|
267
|
+
iface = Interface.objects.create(device=self.device, module=module, name="0", type="10gbase-x-sfpp")
|
|
269
268
|
result = apply_rule_to_existing(rule)
|
|
270
269
|
self.assertEqual(result, 0)
|
|
270
|
+
# Verify interface name is unchanged
|
|
271
|
+
iface.refresh_from_db()
|
|
272
|
+
self.assertEqual(iface.name, "0")
|
|
271
273
|
|
|
272
274
|
def test_renames_matching_interface(self):
|
|
273
275
|
"""apply_rule_to_existing renames interfaces matching the rule."""
|
|
@@ -781,7 +783,7 @@ class EngineHasApplicableExceptionTest(TestCase):
|
|
|
781
783
|
|
|
782
784
|
with patch(
|
|
783
785
|
"netbox_interface_name_rules.engine.find_interfaces_for_rule",
|
|
784
|
-
side_effect=
|
|
786
|
+
side_effect=ValueError("scan fail"),
|
|
785
787
|
):
|
|
786
788
|
result = has_applicable_interfaces(self.rule)
|
|
787
789
|
self.assertFalse(result)
|
|
@@ -984,7 +986,9 @@ class EngineRenameDeviceInterfaceExceptionTest(TestCase):
|
|
|
984
986
|
name_template="GigabitEthernet{vc_position}/{port}",
|
|
985
987
|
)
|
|
986
988
|
Interface.objects.create(device=self.device, name="Gi0/2", type="1000base-t")
|
|
987
|
-
|
|
989
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
990
|
+
|
|
991
|
+
with patch("dcim.models.Interface.full_clean", side_effect=DjangoValidationError("validation fail")):
|
|
988
992
|
apply_device_interface_rules(self.device)
|
|
989
993
|
# Even with exception, no unhandled error
|
|
990
994
|
rule.delete()
|
|
@@ -1217,7 +1221,7 @@ class ApplyRuleExceptionInLoopTest(EngineAdvancedFixtures):
|
|
|
1217
1221
|
def _side_effect(*args, **kwargs):
|
|
1218
1222
|
call_count[0] += 1
|
|
1219
1223
|
if call_count[0] >= 2:
|
|
1220
|
-
raise
|
|
1224
|
+
raise ValueError("forced failure on second call")
|
|
1221
1225
|
return _real_fn(*args, **kwargs)
|
|
1222
1226
|
|
|
1223
1227
|
with patch("netbox_interface_name_rules.engine._apply_rule_to_interface", side_effect=_side_effect):
|
|
@@ -1249,7 +1253,7 @@ class ApplyRuleExceptionInLoopTest(EngineAdvancedFixtures):
|
|
|
1249
1253
|
def _side_effect(*args, **kwargs):
|
|
1250
1254
|
call_count[0] += 1
|
|
1251
1255
|
if call_count[0] >= 2:
|
|
1252
|
-
raise
|
|
1256
|
+
raise ValueError("forced channel failure")
|
|
1253
1257
|
return _real_fn(*args, **kwargs)
|
|
1254
1258
|
|
|
1255
1259
|
with patch("netbox_interface_name_rules.engine._apply_rule_to_interface", side_effect=_side_effect):
|
|
@@ -1400,7 +1404,9 @@ class DeviceInterfaceSaveExceptionTest(TestCase):
|
|
|
1400
1404
|
)
|
|
1401
1405
|
iface = Interface.objects.create(device=self.device, name="Gi0/1", type="1000base-t")
|
|
1402
1406
|
|
|
1403
|
-
|
|
1407
|
+
from django.db import IntegrityError
|
|
1408
|
+
|
|
1409
|
+
with patch.object(Interface, "save", side_effect=IntegrityError("disk full")):
|
|
1404
1410
|
result = _try_rename_device_interface(rule, iface, "1", self.device, set())
|
|
1405
1411
|
|
|
1406
1412
|
self.assertFalse(result)
|
|
@@ -1508,3 +1514,110 @@ class RegexTiebreakerTest(TestCase):
|
|
|
1508
1514
|
self.assertLess(rule_short.pk, rule_long.pk)
|
|
1509
1515
|
matched = find_matching_rule(module_type_specific, None, None)
|
|
1510
1516
|
self.assertEqual(matched, rule_long)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
# ---------------------------------------------------------------------------
|
|
1520
|
+
# engine.py — _find_channel_base with empty interface list
|
|
1521
|
+
# ---------------------------------------------------------------------------
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
class FindChannelBaseEmptyIfacesTest(TestCase):
|
|
1525
|
+
"""Test _find_channel_base handles empty interface list gracefully."""
|
|
1526
|
+
|
|
1527
|
+
def test_empty_ifaces_returns_none(self):
|
|
1528
|
+
"""_find_channel_base returns None when ifaces is empty."""
|
|
1529
|
+
rule = MagicMock()
|
|
1530
|
+
rule.name_template = "port{bay_position}:{channel}"
|
|
1531
|
+
rule.channel_start = 0
|
|
1532
|
+
variables = {"bay_position": "0", "bay_position_num": "0", "slot": "0"}
|
|
1533
|
+
result = _find_channel_base(rule, [], variables)
|
|
1534
|
+
self.assertIsNone(result)
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
# ---------------------------------------------------------------------------
|
|
1538
|
+
# engine.py — find_interfaces_for_rule uses set for processed_pks
|
|
1539
|
+
# ---------------------------------------------------------------------------
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
class FindInterfacesProcessedPksTest(EngineAdvancedFixtures):
|
|
1543
|
+
"""Test find_interfaces_for_rule correctness with multiple modules."""
|
|
1544
|
+
|
|
1545
|
+
def test_multiple_modules_all_counted(self):
|
|
1546
|
+
"""find_interfaces_for_rule processes all matching modules without duplication."""
|
|
1547
|
+
rule = InterfaceNameRule.objects.create(
|
|
1548
|
+
module_type=self.module_type,
|
|
1549
|
+
name_template="et-0/0/{bay_position}",
|
|
1550
|
+
)
|
|
1551
|
+
module0 = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
|
|
1552
|
+
module1 = Module.objects.create(device=self.device, module_bay=self.bay1, module_type=self.module_type)
|
|
1553
|
+
Interface.objects.create(device=self.device, module=module0, name="0", type="10gbase-x-sfpp")
|
|
1554
|
+
Interface.objects.create(device=self.device, module=module1, name="1", type="10gbase-x-sfpp")
|
|
1555
|
+
|
|
1556
|
+
results, total = find_interfaces_for_rule(rule)
|
|
1557
|
+
self.assertEqual(total, 2)
|
|
1558
|
+
self.assertEqual(len(results), 2)
|
|
1559
|
+
result_module_ids = {r["module"].pk for r in results}
|
|
1560
|
+
self.assertEqual(result_module_ids, {module0.pk, module1.pk})
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
# ---------------------------------------------------------------------------
|
|
1564
|
+
# engine.py — breakout transaction rollback on mid-channel failure
|
|
1565
|
+
# ---------------------------------------------------------------------------
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
class BreakoutTransactionRollbackTest(EngineAdvancedFixtures):
|
|
1569
|
+
"""Test that _apply_rule_to_interface rolls back on mid-breakout failure."""
|
|
1570
|
+
|
|
1571
|
+
def test_partial_breakout_rolls_back(self):
|
|
1572
|
+
"""If channel 2 fails validation, channels 0–1 are rolled back too."""
|
|
1573
|
+
rule = InterfaceNameRule.objects.create(
|
|
1574
|
+
module_type=self.module_type,
|
|
1575
|
+
name_template="Hu0/0/0/{bay_position}:{channel}",
|
|
1576
|
+
channel_count=4,
|
|
1577
|
+
channel_start=0,
|
|
1578
|
+
)
|
|
1579
|
+
module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
|
|
1580
|
+
iface = Interface.objects.create(device=self.device, module=module, name="0", type="100gbase-x-qsfp28")
|
|
1581
|
+
|
|
1582
|
+
original_full_clean = Interface.full_clean
|
|
1583
|
+
call_count = [0]
|
|
1584
|
+
|
|
1585
|
+
def failing_full_clean(self_iface, *args, **kwargs):
|
|
1586
|
+
call_count[0] += 1
|
|
1587
|
+
if call_count[0] == 3: # Fail on channel 2
|
|
1588
|
+
from django.core.exceptions import ValidationError
|
|
1589
|
+
|
|
1590
|
+
raise ValidationError("simulated failure")
|
|
1591
|
+
return original_full_clean(self_iface, *args, **kwargs)
|
|
1592
|
+
|
|
1593
|
+
from netbox_interface_name_rules.engine import _apply_rule_to_interface
|
|
1594
|
+
|
|
1595
|
+
variables = build_variables(module.module_bay, device=module.device)
|
|
1596
|
+
variables["base"] = iface.name
|
|
1597
|
+
|
|
1598
|
+
from django.core.exceptions import ValidationError
|
|
1599
|
+
|
|
1600
|
+
with patch.object(Interface, "full_clean", failing_full_clean):
|
|
1601
|
+
with self.assertRaises(ValidationError):
|
|
1602
|
+
_apply_rule_to_interface(rule, iface, variables, module)
|
|
1603
|
+
|
|
1604
|
+
# Transaction rolled back — only the original interface remains
|
|
1605
|
+
iface_names = list(Interface.objects.filter(module=module).values_list("name", flat=True))
|
|
1606
|
+
self.assertEqual(iface_names, ["0"])
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
# ---------------------------------------------------------------------------
|
|
1610
|
+
# engine.py — _get_raw_interface_names with no templates
|
|
1611
|
+
# ---------------------------------------------------------------------------
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
class GetRawInterfaceNamesNoTemplatesTest(EngineAdvancedFixtures):
|
|
1615
|
+
"""Test _get_raw_interface_names when module_type has no InterfaceTemplate entries."""
|
|
1616
|
+
|
|
1617
|
+
def test_no_templates_returns_empty_set(self):
|
|
1618
|
+
"""_get_raw_interface_names returns empty set when module_type has no templates."""
|
|
1619
|
+
from netbox_interface_name_rules.engine import _get_raw_interface_names
|
|
1620
|
+
|
|
1621
|
+
module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
|
|
1622
|
+
result = _get_raw_interface_names(module)
|
|
1623
|
+
self.assertEqual(result, set())
|
|
@@ -122,6 +122,30 @@ class FindMatchingRuleTest(TestCase):
|
|
|
122
122
|
result = find_matching_rule(self.module_type, None, None)
|
|
123
123
|
self.assertIsNone(result)
|
|
124
124
|
|
|
125
|
+
def test_platform_none_does_not_match_platform_scoped_rule(self):
|
|
126
|
+
"""With platform=None, a platform-scoped rule is NOT matched."""
|
|
127
|
+
InterfaceNameRule.objects.create(
|
|
128
|
+
module_type=self.module_type,
|
|
129
|
+
platform=self.platform,
|
|
130
|
+
name_template="platform-only{bay_position}",
|
|
131
|
+
)
|
|
132
|
+
result = find_matching_rule(self.module_type, None, None, platform=None)
|
|
133
|
+
self.assertIsNone(result)
|
|
134
|
+
|
|
135
|
+
def test_platform_none_falls_back_to_unscoped_rule(self):
|
|
136
|
+
"""With platform=None, an unscoped rule is preferred over a platform-scoped one."""
|
|
137
|
+
InterfaceNameRule.objects.create(
|
|
138
|
+
module_type=self.module_type,
|
|
139
|
+
platform=self.platform,
|
|
140
|
+
name_template="platform{bay_position}",
|
|
141
|
+
)
|
|
142
|
+
unscoped = InterfaceNameRule.objects.create(
|
|
143
|
+
module_type=self.module_type,
|
|
144
|
+
name_template="generic{bay_position}",
|
|
145
|
+
)
|
|
146
|
+
result = find_matching_rule(self.module_type, None, None, platform=None)
|
|
147
|
+
self.assertEqual(result, unscoped)
|
|
148
|
+
|
|
125
149
|
|
|
126
150
|
class BuildVariablesTest(TestCase):
|
|
127
151
|
"""Test build_variables from module bay context."""
|
|
@@ -504,3 +504,40 @@ class ModuleDeletionCascadeTest(TestCase):
|
|
|
504
504
|
|
|
505
505
|
# Interface was cascade-deleted with the module
|
|
506
506
|
self.assertFalse(Interface.objects.filter(pk=iface_pk).exists())
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class ModulePreSaveExceptionLoggingTest(TestCase):
|
|
510
|
+
"""Test on_module_pre_save logs exceptions instead of silently swallowing them."""
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def setUpTestData(cls):
|
|
514
|
+
mfg = Manufacturer.objects.create(name="PreSaveLogMfg", slug="presavelogmfg")
|
|
515
|
+
cls.device_type = DeviceType.objects.create(manufacturer=mfg, model="PSL-Dev", slug="psl-dev")
|
|
516
|
+
cls.module_type = ModuleType.objects.create(manufacturer=mfg, model="PSL-SFP", part_number="PSL-SFP")
|
|
517
|
+
ModuleBayTemplate.objects.create(device_type=cls.device_type, name="Bay 0", position="0")
|
|
518
|
+
role = DeviceRole.objects.create(name="PSLRole", slug="pslrole")
|
|
519
|
+
site = Site.objects.create(name="PSLSite", slug="pslsite")
|
|
520
|
+
cls.device = Device.objects.create(name="psl-dev-01", device_type=cls.device_type, role=role, site=site)
|
|
521
|
+
cls.bay = ModuleBay.objects.get(device=cls.device, name="Bay 0")
|
|
522
|
+
|
|
523
|
+
def test_pre_save_db_error_logs_warning(self):
|
|
524
|
+
"""on_module_pre_save logs a warning when DB lookup fails."""
|
|
525
|
+
from django.db import DatabaseError
|
|
526
|
+
|
|
527
|
+
module = Module.objects.create(device=self.device, module_bay=self.bay, module_type=self.module_type)
|
|
528
|
+
with patch.object(Module.objects, "filter", side_effect=DatabaseError("db error")):
|
|
529
|
+
with self.assertLogs("netbox_interface_name_rules", level="WARNING") as cm:
|
|
530
|
+
on_module_pre_save(Module, module)
|
|
531
|
+
self.assertIsNone(module._prev_module_type_id)
|
|
532
|
+
self.assertTrue(any("db error" in msg for msg in cm.output))
|
|
533
|
+
|
|
534
|
+
def test_device_pre_save_db_error_logs_warning(self):
|
|
535
|
+
"""on_device_pre_save logs a warning when DB lookup fails."""
|
|
536
|
+
from django.db import DatabaseError
|
|
537
|
+
|
|
538
|
+
with patch.object(Device.objects, "filter", side_effect=DatabaseError("db error")):
|
|
539
|
+
with self.assertLogs("netbox_interface_name_rules", level="WARNING") as cm:
|
|
540
|
+
on_device_pre_save(Device, self.device)
|
|
541
|
+
self.assertIsNone(self.device._prev_virtual_chassis_id)
|
|
542
|
+
self.assertIsNone(self.device._prev_vc_position)
|
|
543
|
+
self.assertTrue(any("db error" in msg for msg in cm.output))
|
|
@@ -369,6 +369,54 @@ class RuleTestViewGetWithRuleIdTest(ViewTestBase2):
|
|
|
369
369
|
self.assertEqual(response.status_code, 200)
|
|
370
370
|
|
|
371
371
|
|
|
372
|
+
class RuleTestViewPermissionTest(ViewTestBase2):
|
|
373
|
+
"""Test RuleTestView permission checks for unprivileged users."""
|
|
374
|
+
|
|
375
|
+
def _url(self):
|
|
376
|
+
return reverse("plugins:netbox_interface_name_rules:interfacenamerule_test")
|
|
377
|
+
|
|
378
|
+
def test_get_no_permission_raises_403(self):
|
|
379
|
+
"""GET to rule test view without add_interfacenamerule permission returns 403."""
|
|
380
|
+
User.objects.create_user(username="noperm_test_get", password=TEST_PASSWORD)
|
|
381
|
+
self.client.login(username="noperm_test_get", password=TEST_PASSWORD)
|
|
382
|
+
response = self.client.get(self._url())
|
|
383
|
+
self.assertEqual(response.status_code, 403)
|
|
384
|
+
|
|
385
|
+
def test_get_with_rule_id_no_permission_raises_403(self):
|
|
386
|
+
"""GET with ?rule_id= without add_interfacenamerule permission returns 403."""
|
|
387
|
+
User.objects.create_user(username="noperm_test_get2", password=TEST_PASSWORD)
|
|
388
|
+
self.client.login(username="noperm_test_get2", password=TEST_PASSWORD)
|
|
389
|
+
response = self.client.get(self._url() + f"?rule_id={self.rule.pk}")
|
|
390
|
+
self.assertEqual(response.status_code, 403)
|
|
391
|
+
|
|
392
|
+
def test_post_no_permission_raises_403(self):
|
|
393
|
+
"""POST to rule test view without add_interfacenamerule permission returns 403."""
|
|
394
|
+
User.objects.create_user(username="noperm_test_post", password=TEST_PASSWORD)
|
|
395
|
+
self.client.login(username="noperm_test_post", password=TEST_PASSWORD)
|
|
396
|
+
data = {
|
|
397
|
+
"name_template": "et-0/0/{bay_position}",
|
|
398
|
+
"channel_count": "0",
|
|
399
|
+
"channel_start": "0",
|
|
400
|
+
}
|
|
401
|
+
response = self.client.post(self._url(), data)
|
|
402
|
+
self.assertEqual(response.status_code, 403)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class RuleApplyDetailViewGetPermissionTest(ViewTestBase2):
|
|
406
|
+
"""Test RuleApplyDetailView.get() permission check for unprivileged users."""
|
|
407
|
+
|
|
408
|
+
def test_get_no_change_permission_raises_403(self):
|
|
409
|
+
"""GET apply detail without dcim.change_interface permission returns 403."""
|
|
410
|
+
User.objects.create_user(username="noperm_apply_get", password=TEST_PASSWORD)
|
|
411
|
+
self.client.login(username="noperm_apply_get", password=TEST_PASSWORD)
|
|
412
|
+
url = reverse(
|
|
413
|
+
"plugins:netbox_interface_name_rules:interfacenamerule_apply_detail",
|
|
414
|
+
kwargs={"pk": self.rule.pk},
|
|
415
|
+
)
|
|
416
|
+
response = self.client.get(url)
|
|
417
|
+
self.assertEqual(response.status_code, 403)
|
|
418
|
+
|
|
419
|
+
|
|
372
420
|
class RuleApplyDetailViewPostTest(ViewTestBase2):
|
|
373
421
|
"""Test RuleApplyDetailView.post() — foreground apply, background job, no permission."""
|
|
374
422
|
|
|
@@ -10,6 +10,7 @@ from django.core.exceptions import PermissionDenied
|
|
|
10
10
|
from django.http import JsonResponse
|
|
11
11
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
12
12
|
from django.urls import reverse
|
|
13
|
+
from django.utils.http import url_has_allowed_host_and_scheme
|
|
13
14
|
from django.views import View
|
|
14
15
|
from netbox.views import generic
|
|
15
16
|
from utilities.views import ConditionalLoginRequiredMixin, register_model_view
|
|
@@ -115,7 +116,7 @@ class InterfaceNameRuleDuplicateView(ConditionalLoginRequiredMixin, View):
|
|
|
115
116
|
"""Redirect to the add view pre-populated with fields cloned from rule pk."""
|
|
116
117
|
from utilities.querydict import prepare_cloned_fields
|
|
117
118
|
|
|
118
|
-
rule = get_object_or_404(InterfaceNameRule
|
|
119
|
+
rule = get_object_or_404(InterfaceNameRule, pk=pk)
|
|
119
120
|
params = prepare_cloned_fields(rule)
|
|
120
121
|
url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add")
|
|
121
122
|
return redirect(f"{url}?{params.urlencode()}")
|
|
@@ -126,8 +127,13 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
|
|
|
126
127
|
|
|
127
128
|
template_name = "netbox_interface_name_rules/rule_test.html"
|
|
128
129
|
|
|
130
|
+
def _check_permission(self, request):
|
|
131
|
+
if not request.user.has_perm("netbox_interface_name_rules.add_interfacenamerule"):
|
|
132
|
+
raise PermissionDenied
|
|
133
|
+
|
|
129
134
|
def get(self, request):
|
|
130
135
|
"""Render the test form, pre-populated from rule_id query param if given."""
|
|
136
|
+
self._check_permission(request)
|
|
131
137
|
initial = {}
|
|
132
138
|
loaded_rule = None
|
|
133
139
|
rule_id = request.GET.get("rule_id")
|
|
@@ -153,6 +159,7 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
|
|
|
153
159
|
|
|
154
160
|
def post(self, request):
|
|
155
161
|
"""Evaluate the submitted template and return a preview or redirect to save."""
|
|
162
|
+
self._check_permission(request)
|
|
156
163
|
form = RuleTestForm(request.POST)
|
|
157
164
|
preview_results = None
|
|
158
165
|
db_preview = None
|
|
@@ -347,10 +354,15 @@ class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
|
|
|
347
354
|
|
|
348
355
|
template_name = "netbox_interface_name_rules/rule_apply_detail.html"
|
|
349
356
|
|
|
357
|
+
def _check_permission(self, request):
|
|
358
|
+
if not request.user.has_perm("dcim.change_interface"):
|
|
359
|
+
raise PermissionDenied
|
|
360
|
+
|
|
350
361
|
def get(self, request, pk):
|
|
351
362
|
"""Render a preview of all interfaces that would be renamed by this rule."""
|
|
352
363
|
from .engine import find_interfaces_for_rule
|
|
353
364
|
|
|
365
|
+
self._check_permission(request)
|
|
354
366
|
rule = get_object_or_404(InterfaceNameRule, pk=pk)
|
|
355
367
|
try:
|
|
356
368
|
preview, total_checked = find_interfaces_for_rule(rule, limit=APPLY_BATCH_LIMIT)
|
|
@@ -379,8 +391,7 @@ class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
|
|
|
379
391
|
"""Apply the rule (foreground batch or background job) and redirect back."""
|
|
380
392
|
from .engine import apply_rule_to_existing
|
|
381
393
|
|
|
382
|
-
|
|
383
|
-
raise PermissionDenied
|
|
394
|
+
self._check_permission(request)
|
|
384
395
|
|
|
385
396
|
rule = get_object_or_404(InterfaceNameRule, pk=pk)
|
|
386
397
|
action = request.POST.get("action", "apply")
|
|
@@ -436,8 +447,6 @@ class RuleToggleView(generic.ObjectView):
|
|
|
436
447
|
return JsonResponse({"enabled": rule.enabled, "pk": pk})
|
|
437
448
|
state = "enabled" if rule.enabled else "disabled"
|
|
438
449
|
messages.success(request, f"Rule '{rule}' {state}.")
|
|
439
|
-
from django.utils.http import url_has_allowed_host_and_scheme
|
|
440
|
-
|
|
441
450
|
referer = request.META.get("HTTP_REFERER", "")
|
|
442
451
|
if referer and url_has_allowed_host_and_scheme(
|
|
443
452
|
referer, allowed_hosts={request.get_host()}, require_https=request.is_secure()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-interface-name-rules
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
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"/>
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "netbox-interface-name-rules"
|
|
9
|
-
version = "1.2.
|
|
9
|
+
version = "1.2.3"
|
|
10
10
|
description = "NetBox plugin for automatic interface renaming when modules are installed"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.12.0"
|
|
@@ -57,7 +57,7 @@ dev = [
|
|
|
57
57
|
"pytest-django",
|
|
58
58
|
"pytest-cov>=6.0",
|
|
59
59
|
"python-semantic-release",
|
|
60
|
-
"django>=5.1,<
|
|
60
|
+
"django>=5.1,<7.0",
|
|
61
61
|
]
|
|
62
62
|
docs = [
|
|
63
63
|
"mkdocs>=1,<2",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|