netbox-interface-name-rules 1.2.1__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.1/netbox_interface_name_rules.egg-info → netbox_interface_name_rules-1.2.3}/PKG-INFO +29 -1
- netbox_interface_name_rules-1.2.1/PKG-INFO → netbox_interface_name_rules-1.2.3/README.md +19 -14
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/__init__.py +3 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/serializers.py +6 -7
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/engine.py +33 -15
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/filters.py +1 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/models.py +0 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/signals.py +2 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +115 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +164 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +180 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +210 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +258 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_api.py +14 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_device_rules.py +46 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine.py +12 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_engine_advanced.py +1623 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_misc.py +307 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_regex.py +1 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_rules.py +24 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_signals.py +543 -0
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_views.py +662 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/views.py +14 -5
- netbox_interface_name_rules-1.2.1/README.md → netbox_interface_name_rules-1.2.3/netbox_interface_name_rules.egg-info/PKG-INFO +42 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +5 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/pyproject.toml +30 -6
- netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_engine_advanced.py +0 -680
- netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_signals.py +0 -258
- netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_views.py +0 -328
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/LICENSE +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/urls.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/views.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/forms.py +1 -1
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/jobs.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
- {netbox_interface_name_rules-1.2.1 → 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.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
- {netbox_interface_name_rules-1.2.1 → 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.1 → 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.1 → 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.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
- {netbox_interface_name_rules-1.2.1 → 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.1 → 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.1 → 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.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/navigation.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tables.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/urls.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/utils.py +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
- {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/setup.cfg +0 -0
|
@@ -1,12 +1,21 @@
|
|
|
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
|
+
Author: Marcin Zieba
|
|
5
6
|
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/
|
|
8
|
+
Project-URL: Repository, https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin
|
|
9
|
+
Project-URL: Issues, https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/issues
|
|
10
|
+
Project-URL: Documentation, https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/
|
|
11
|
+
Project-URL: Changelog, https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: netbox,plugin,dcim,interface,naming,modules,transceiver,automation
|
|
6
13
|
Classifier: Programming Language :: Python :: 3
|
|
7
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
8
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
9
16
|
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Environment :: Plugins
|
|
18
|
+
Classifier: Topic :: System :: Networking
|
|
10
19
|
Requires-Python: >=3.12.0
|
|
11
20
|
Description-Content-Type: text/markdown
|
|
12
21
|
License-File: LICENSE
|
|
@@ -22,6 +31,7 @@ Dynamic: license-file
|
|
|
22
31
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
23
32
|
[](https://github.com/netbox-community/netbox)
|
|
24
33
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
|
|
34
|
+
[](https://api.reuse.software/info/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)
|
|
25
35
|
|
|
26
36
|
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
27
37
|
|
|
@@ -63,6 +73,24 @@ Add to `configuration.py`:
|
|
|
63
73
|
PLUGINS = ['netbox_interface_name_rules']
|
|
64
74
|
```
|
|
65
75
|
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Rules are managed through the NetBox UI under **Plugins → Interface Name Rules**, or via the REST API at `/api/plugins/interface-name-rules/rules/`.
|
|
79
|
+
|
|
80
|
+
See the [full configuration guide](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/) for all rule fields, priority scoring, and template variable reference.
|
|
81
|
+
|
|
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
|
+
|
|
87
|
+
<p align="center">
|
|
88
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/01-rule-list.png" alt="Rule list" width="700"/>
|
|
89
|
+
</p>
|
|
90
|
+
<p align="center">
|
|
91
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/11-apply-rule-preview.png" alt="Apply-rules preview" width="700"/>
|
|
92
|
+
</p>
|
|
93
|
+
|
|
66
94
|
## Compatibility
|
|
67
95
|
|
|
68
96
|
- NetBox ≥ 4.2.0
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: netbox-interface-name-rules
|
|
3
|
-
Version: 1.2.1
|
|
4
|
-
Summary: NetBox plugin for automatic interface renaming when modules are installed
|
|
5
|
-
License-Expression: Apache-2.0
|
|
6
|
-
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
-
Classifier: Framework :: Django
|
|
10
|
-
Requires-Python: >=3.12.0
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
License-File: LICENSE
|
|
13
|
-
Dynamic: license-file
|
|
14
|
-
|
|
15
1
|
# NetBox Interface Name Rules Plugin
|
|
16
2
|
|
|
17
3
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
@@ -22,6 +8,7 @@ Dynamic: license-file
|
|
|
22
8
|
[](https://pypi.org/project/netbox-interface-name-rules/)
|
|
23
9
|
[](https://github.com/netbox-community/netbox)
|
|
24
10
|
[](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
|
|
11
|
+
[](https://api.reuse.software/info/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)
|
|
25
12
|
|
|
26
13
|
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
27
14
|
|
|
@@ -63,6 +50,24 @@ Add to `configuration.py`:
|
|
|
63
50
|
PLUGINS = ['netbox_interface_name_rules']
|
|
64
51
|
```
|
|
65
52
|
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Rules are managed through the NetBox UI under **Plugins → Interface Name Rules**, or via the REST API at `/api/plugins/interface-name-rules/rules/`.
|
|
56
|
+
|
|
57
|
+
See the [full configuration guide](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/) for all rule fields, priority scoring, and template variable reference.
|
|
58
|
+
|
|
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
|
+
|
|
64
|
+
<p align="center">
|
|
65
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/01-rule-list.png" alt="Rule list" width="700"/>
|
|
66
|
+
</p>
|
|
67
|
+
<p align="center">
|
|
68
|
+
<img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/screenshots/11-apply-rule-preview.png" alt="Apply-rules preview" width="700"/>
|
|
69
|
+
</p>
|
|
70
|
+
|
|
66
71
|
## Compatibility
|
|
67
72
|
|
|
68
73
|
- NetBox ≥ 4.2.0
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
3
|
from netbox.plugins import PluginConfig
|
|
4
4
|
|
|
5
|
-
__version__ = "1.2.
|
|
5
|
+
__version__ = "1.2.3"
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class InterfaceNameRulesConfig(PluginConfig):
|
|
@@ -16,6 +16,8 @@ class InterfaceNameRulesConfig(PluginConfig):
|
|
|
16
16
|
min_version = "4.2.0"
|
|
17
17
|
required_settings = []
|
|
18
18
|
default_settings = {}
|
|
19
|
+
author = "Marcin Zieba"
|
|
20
|
+
author_email = "marcinpsk@gmail.com"
|
|
19
21
|
|
|
20
22
|
def ready(self):
|
|
21
23
|
"""Connect signal handlers after all apps are loaded."""
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
-
from rest_framework import serializers
|
|
4
|
-
|
|
5
3
|
from netbox.api.serializers import NetBoxModelSerializer
|
|
4
|
+
from rest_framework import serializers
|
|
6
5
|
|
|
7
6
|
from netbox_interface_name_rules.models import InterfaceNameRule
|
|
8
7
|
|
|
@@ -43,25 +42,25 @@ class InterfaceNameRuleSerializer(NetBoxModelSerializer):
|
|
|
43
42
|
|
|
44
43
|
if is_device_level:
|
|
45
44
|
# Device-level rules must not have a module_type FK
|
|
46
|
-
if module_type:
|
|
45
|
+
if module_type: # pragma: no cover # model.clean() runs first
|
|
47
46
|
raise serializers.ValidationError(
|
|
48
47
|
{"module_type": "Module type must be empty for device-level interface rules."}
|
|
49
48
|
)
|
|
50
49
|
elif is_regex:
|
|
51
|
-
if not pattern:
|
|
50
|
+
if not pattern: # pragma: no cover # model.clean() runs first
|
|
52
51
|
raise serializers.ValidationError(
|
|
53
52
|
{"module_type_pattern": "Regex pattern is required when regex mode is enabled."}
|
|
54
53
|
)
|
|
55
|
-
if module_type:
|
|
54
|
+
if module_type: # pragma: no cover # model.clean() runs first
|
|
56
55
|
raise serializers.ValidationError(
|
|
57
56
|
{"module_type": "Cannot set both module_type and module_type_pattern. Choose one."}
|
|
58
57
|
)
|
|
59
58
|
else:
|
|
60
|
-
if not module_type:
|
|
59
|
+
if not module_type: # pragma: no cover # model.clean() runs first
|
|
61
60
|
raise serializers.ValidationError(
|
|
62
61
|
{"module_type": "module_type is required when regex mode is disabled."}
|
|
63
62
|
)
|
|
64
|
-
if pattern:
|
|
63
|
+
if pattern: # pragma: no cover # model.clean() runs first
|
|
65
64
|
raise serializers.ValidationError(
|
|
66
65
|
{"module_type_pattern": "Cannot set module_type_pattern when regex mode is disabled."}
|
|
67
66
|
)
|
|
@@ -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,
|
|
@@ -831,5 +850,4 @@ def evaluate_name_template(template: str, variables: dict) -> str:
|
|
|
831
850
|
except (SyntaxError, TypeError) as e:
|
|
832
851
|
raise ValueError(f"Invalid arithmetic expression '{expr}': {e}") from e
|
|
833
852
|
|
|
834
|
-
|
|
835
|
-
return result
|
|
853
|
+
return re.sub(r"\{([^}]+)\}", _eval_expr, result)
|
|
@@ -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
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
{# SPDX-License-Identifier: Apache-2.0 #}
|
|
2
|
+
{# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> #}
|
|
3
|
+
{% extends 'generic/object.html' %}
|
|
4
|
+
{% load helpers %}
|
|
5
|
+
{% load plugins %}
|
|
6
|
+
|
|
7
|
+
{% block extra_controls %}
|
|
8
|
+
<a href="{% url 'plugins:netbox_interface_name_rules:interfacenamerule_test' %}?rule_id={{ object.pk }}"
|
|
9
|
+
class="btn btn-outline-secondary">
|
|
10
|
+
<i class="mdi mdi-flask-outline me-1"></i>Test in Build Rule
|
|
11
|
+
</a>
|
|
12
|
+
{% if perms.netbox_interface_name_rules.add_interfacenamerule %}
|
|
13
|
+
<a href="{% url 'plugins:netbox_interface_name_rules:interfacenamerule_duplicate' object.pk %}"
|
|
14
|
+
class="btn btn-success">
|
|
15
|
+
<i class="mdi mdi-content-copy me-1"></i>Duplicate
|
|
16
|
+
</a>
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% endblock extra_controls %}
|
|
19
|
+
|
|
20
|
+
{% block content %}
|
|
21
|
+
{# ── Priority callout ───────────────────────────────────────────────────────── #}
|
|
22
|
+
<div class="alert alert-info d-flex align-items-start mb-3" role="alert">
|
|
23
|
+
<i class="mdi mdi-information-outline me-2 mt-1 fs-5"></i>
|
|
24
|
+
<div>
|
|
25
|
+
<strong>Priority score: {{ object.specificity_score }}</strong>
|
|
26
|
+
<span class="text-muted ms-1">({{ object.specificity_label }})</span>
|
|
27
|
+
<br>
|
|
28
|
+
<small>
|
|
29
|
+
This score is <em>auto-computed</em> — you cannot set it directly.
|
|
30
|
+
It controls which rule wins when multiple rules match the same module.
|
|
31
|
+
<strong>Higher score = higher priority.</strong>
|
|
32
|
+
Exact FK rules always outrank regex rules (score 1000+ vs max ≤ 955).
|
|
33
|
+
Among regex rules, adding scope fields raises the score:
|
|
34
|
+
<code>parent_module_type</code> +4×100,
|
|
35
|
+
<code>device_type</code> +2×100,
|
|
36
|
+
<code>platform</code> +1×100,
|
|
37
|
+
plus pattern length.
|
|
38
|
+
<a data-bs-toggle="collapse" href="#priority-detail" class="ms-1">More…</a>
|
|
39
|
+
</small>
|
|
40
|
+
<div class="collapse mt-2" id="priority-detail">
|
|
41
|
+
<table class="table table-sm table-bordered mb-0 small">
|
|
42
|
+
<thead class="table-light"><tr><th>Rule type</th><th>Score formula</th><th>Range</th></tr></thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
<tr><td>Exact FK (module_type)</td><td>1000 + scope</td><td>1000 – 1007</td></tr>
|
|
45
|
+
<tr><td>Regex (module_type_pattern)</td><td>scope × 100 + len(pattern)</td><td>0 – 955</td></tr>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
<p class="small mb-0 mt-1">
|
|
49
|
+
Scope bits: <code>parent_module_type</code>=4, <code>device_type</code>=2, <code>platform</code>=1.
|
|
50
|
+
Two rules with the same score: lowest <code>pk</code> (oldest rule) wins.
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="row mb-3">
|
|
57
|
+
<div class="col col-md-12">
|
|
58
|
+
<div class="card">
|
|
59
|
+
<table class="table table-hover attr-table">
|
|
60
|
+
<thead>
|
|
61
|
+
<tr>
|
|
62
|
+
<th>Module Type</th>
|
|
63
|
+
<th>Parent Module Type</th>
|
|
64
|
+
<th>Device Type</th>
|
|
65
|
+
<th>Platform</th>
|
|
66
|
+
<th>Name Template</th>
|
|
67
|
+
<th>Channels</th>
|
|
68
|
+
<th>Channel Start</th>
|
|
69
|
+
<th>Description</th>
|
|
70
|
+
</tr>
|
|
71
|
+
</thead>
|
|
72
|
+
<tbody>
|
|
73
|
+
<tr>
|
|
74
|
+
<td>
|
|
75
|
+
{% if object.module_type_is_regex %}
|
|
76
|
+
<code>{{ object.module_type_pattern }}</code>
|
|
77
|
+
<span class="badge text-bg-info">regex</span>
|
|
78
|
+
{% elif object.module_type %}
|
|
79
|
+
<a href="{{ object.module_type.get_absolute_url }}">{{ object.module_type }}</a>
|
|
80
|
+
{% else %}
|
|
81
|
+
<span class="text-muted">—</span>
|
|
82
|
+
{% endif %}
|
|
83
|
+
</td>
|
|
84
|
+
<td>
|
|
85
|
+
{% if object.parent_module_type %}
|
|
86
|
+
<a href="{{ object.parent_module_type.get_absolute_url }}">{{ object.parent_module_type }}</a>
|
|
87
|
+
{% else %}
|
|
88
|
+
<span class="text-muted">Any parent</span>
|
|
89
|
+
{% endif %}
|
|
90
|
+
</td>
|
|
91
|
+
<td>
|
|
92
|
+
{% if object.device_type %}
|
|
93
|
+
<a href="{{ object.device_type.get_absolute_url }}">{{ object.device_type }}</a>
|
|
94
|
+
{% else %}
|
|
95
|
+
<span class="text-muted">Any device</span>
|
|
96
|
+
{% endif %}
|
|
97
|
+
</td>
|
|
98
|
+
<td>
|
|
99
|
+
{% if object.platform %}
|
|
100
|
+
<a href="{{ object.platform.get_absolute_url }}">{{ object.platform }}</a>
|
|
101
|
+
{% else %}
|
|
102
|
+
<span class="text-muted">Any platform</span>
|
|
103
|
+
{% endif %}
|
|
104
|
+
</td>
|
|
105
|
+
<td><code>{{ object.name_template }}</code></td>
|
|
106
|
+
<td>{{ object.channel_count|default:"—" }}</td>
|
|
107
|
+
<td>{{ object.channel_start }}</td>
|
|
108
|
+
<td>{{ object.description|default:"—" }}</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
{% endblock %}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
{# SPDX-License-Identifier: Apache-2.0 #}
|
|
2
|
+
{# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> #}
|
|
3
|
+
{% extends 'generic/object_list.html' %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="card mb-3" id="interface-name-rules-help">
|
|
7
|
+
<div class="card-header" role="button" tabindex="0" style="cursor: pointer;"
|
|
8
|
+
id="rules-help-header" aria-expanded="true" aria-controls="rules-help-body"
|
|
9
|
+
onclick="toggleRulesHelp()"
|
|
10
|
+
onkeydown="if(event.key==='Enter'||event.key===' '){toggleRulesHelp();event.preventDefault();}">
|
|
11
|
+
<h5 class="card-title mb-0 d-flex align-items-center">
|
|
12
|
+
<i class="mdi mdi-information-outline me-2"></i>
|
|
13
|
+
Interface Name Rules Reference
|
|
14
|
+
<i class="mdi ms-auto" id="rules-help-chevron"></i>
|
|
15
|
+
</h5>
|
|
16
|
+
</div>
|
|
17
|
+
<div id="rules-help-body" class="card-body">
|
|
18
|
+
<p>Define post-install interface rename rules for module types where NetBox's
|
|
19
|
+
position-based naming can't produce the correct interface name.</p>
|
|
20
|
+
<p>Covers <strong>converter offset</strong> (e.g., CVR-X2-SFP + GLC-T),
|
|
21
|
+
<strong>breakout transceivers</strong> (e.g., QSFP+ 4x10G with channel numbering),
|
|
22
|
+
<strong>platform-specific naming</strong> (e.g., Juniper et-/xe-/ge- prefixes),
|
|
23
|
+
and <strong>Virtual Chassis device port renaming</strong> (e.g., Gi0/1 → Gi2/1 on VC member 2).</p>
|
|
24
|
+
|
|
25
|
+
<p><strong>Signal-driven:</strong> Rules run automatically via two Django signals:</p>
|
|
26
|
+
<ul>
|
|
27
|
+
<li><code>post_save</code> on <strong>Module</strong> — fires after a module is installed into a device bay (primary path for transceiver/linecard naming).</li>
|
|
28
|
+
<li><code>post_save</code> on <strong>Device</strong> — fires after VC position changes (<code>vc_position</code> or <code>virtual_chassis</code> modified); triggers device-level interface renaming for VC members.</li>
|
|
29
|
+
</ul>
|
|
30
|
+
<p>Both paths use <code>on_commit</code> deferred execution so that all interfaces are in the database before renaming begins.</p>
|
|
31
|
+
|
|
32
|
+
<p><strong>Device interface rules</strong> (<code>Applies to Device Interfaces</code> checkbox) target native device-type interfaces
|
|
33
|
+
(<code>module=None</code>) rather than module-installed interfaces. Use these to rename VC member ports when the device
|
|
34
|
+
joins a stack or changes position. The <code>Module Type Pattern</code> field acts as an interface-name filter (regex) instead of a module type selector.</p>
|
|
35
|
+
|
|
36
|
+
<h6 class="mt-3">Supported Template Variables</h6>
|
|
37
|
+
<table class="table table-sm table-bordered" style="max-width: 750px;">
|
|
38
|
+
<thead>
|
|
39
|
+
<tr><th style="width: 200px;">Variable</th><th>Rule type</th><th>Description</th></tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<tr><td><code>{slot}</code></td><td>Module</td><td>Top-level slot/module bay position (from grandparent or parent)</td></tr>
|
|
43
|
+
<tr><td><code>{bay_position}</code></td><td>Module</td><td>Position of the bay this module is installed into (raw, e.g., "swp1")</td></tr>
|
|
44
|
+
<tr><td><code>{bay_position_num}</code></td><td>Module</td><td>Numeric suffix of bay position (e.g., "swp1" → "1")</td></tr>
|
|
45
|
+
<tr><td><code>{parent_bay_position}</code></td><td>Module</td><td>Position of the parent module's bay</td></tr>
|
|
46
|
+
<tr><td><code>{sfp_slot}</code></td><td>Module</td><td>Numeric sub-bay index within the parent module</td></tr>
|
|
47
|
+
<tr><td><code>{base}</code></td><td>Both</td><td>Original interface name from the template (or current name for device-level rules)</td></tr>
|
|
48
|
+
<tr><td><code>{port}</code></td><td>Device</td><td>Segment after the last "/" in the current interface name (e.g., "Gi0/1" → "1"); full name if no "/"</td></tr>
|
|
49
|
+
<tr><td><code>{channel}</code></td><td>Module</td><td>Breakout channel number (iterated from Ch. Start to Ch. Start + Channels - 1)</td></tr>
|
|
50
|
+
<tr><td><code>{vc_position}</code></td><td>Both</td><td>Virtual Chassis member position (<code>device.vc_position</code>); requires device to be in a VC</td></tr>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
|
|
54
|
+
<h6 class="mt-3">Arithmetic Expressions</h6>
|
|
55
|
+
<p>Arithmetic is supported inside braces:
|
|
56
|
+
<code>{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}</code></p>
|
|
57
|
+
|
|
58
|
+
<h6 class="mt-3">Examples</h6>
|
|
59
|
+
<ul class="mb-0">
|
|
60
|
+
<li><strong>Converter offset:</strong> GLC-T in CVR-X2-SFP →
|
|
61
|
+
<code>GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}</code></li>
|
|
62
|
+
<li><strong>Breakout:</strong> QSFP-4X10G-LR → <code>{base}:{channel}</code>
|
|
63
|
+
(Channels=4, Ch. Start=0)</li>
|
|
64
|
+
<li><strong>Platform naming:</strong> QSFP-100G-LR4 on ACX7024 →
|
|
65
|
+
<code>et-0/0/{bay_position}</code> (scoped via Parent Device Type)</li>
|
|
66
|
+
<li><strong>UfiSpace breakout:</strong> QSFP-100G-LR4 on S9610-36D →
|
|
67
|
+
<code>swp{bay_position_num}s{channel}</code> (Channels=2, Ch. Start=1)</li>
|
|
68
|
+
<li><strong>Virtual Chassis linecard:</strong> VC-LINECARD on stack member →
|
|
69
|
+
<code>Gi{vc_position}/{bay_position_num}</code> (requires device to be in a Virtual Chassis)</li>
|
|
70
|
+
<li><strong>VC device ports (Cisco):</strong> GigabitEthernet1/0/3 on stack member 2 →
|
|
71
|
+
<code>GigabitEthernet{vc_position}/0/{port}</code> → GigabitEthernet2/0/3
|
|
72
|
+
(device-level rule, pattern <code>GigabitEthernet\d+/\d+/\d+</code>)</li>
|
|
73
|
+
<li><strong>VC device ports (Juniper):</strong> ge-0/0/2 on VC member 1 →
|
|
74
|
+
<code>ge-{vc_position}/0/{port}</code> → ge-1/0/2
|
|
75
|
+
(device-level rule, pattern <code>ge-\d+/\d+/\d+</code>)</li>
|
|
76
|
+
<li><strong>VC device ports (Arista):</strong> Ethernet1/3 on VC member 2 →
|
|
77
|
+
<code>Ethernet{vc_position}/{port}</code> → Ethernet2/3
|
|
78
|
+
(device-level rule, pattern <code>Ethernet\d+/\d+</code>)</li>
|
|
79
|
+
</ul>
|
|
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
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<script>
|
|
101
|
+
function toggleRulesHelp() {
|
|
102
|
+
const body = document.getElementById('rules-help-body');
|
|
103
|
+
const header = document.getElementById('rules-help-header');
|
|
104
|
+
const chevron = document.getElementById('rules-help-chevron');
|
|
105
|
+
const collapsed = body.style.display === 'none';
|
|
106
|
+
body.style.display = collapsed ? '' : 'none';
|
|
107
|
+
chevron.className = collapsed ? 'mdi ms-auto mdi-chevron-up' : 'mdi ms-auto mdi-chevron-down';
|
|
108
|
+
header.setAttribute('aria-expanded', collapsed ? 'true' : 'false');
|
|
109
|
+
localStorage.setItem('rulesHelpCollapsed', collapsed ? '' : '1');
|
|
110
|
+
}
|
|
111
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
112
|
+
const collapsed = localStorage.getItem('rulesHelpCollapsed') === '1';
|
|
113
|
+
const body = document.getElementById('rules-help-body');
|
|
114
|
+
const header = document.getElementById('rules-help-header');
|
|
115
|
+
const chevron = document.getElementById('rules-help-chevron');
|
|
116
|
+
body.style.display = collapsed ? 'none' : '';
|
|
117
|
+
chevron.className = collapsed ? 'mdi ms-auto mdi-chevron-down' : 'mdi ms-auto mdi-chevron-up';
|
|
118
|
+
header.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
119
|
+
|
|
120
|
+
// AJAX toggle: click handler on toggle buttons (no form — avoids nested-form 405 bug)
|
|
121
|
+
document.addEventListener('click', function(e) {
|
|
122
|
+
const btn = e.target.closest('.toggle-btn[data-toggle-url]');
|
|
123
|
+
if (!btn) return;
|
|
124
|
+
const url = btn.dataset.toggleUrl;
|
|
125
|
+
const csrfToken =
|
|
126
|
+
document.querySelector('input[name="csrfmiddlewaretoken"]')?.value ||
|
|
127
|
+
(document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/) || [])[1] || '';
|
|
128
|
+
btn.disabled = true;
|
|
129
|
+
fetch(url, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrfToken},
|
|
132
|
+
credentials: 'same-origin',
|
|
133
|
+
}).then(r => {
|
|
134
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
135
|
+
return r.json();
|
|
136
|
+
}).then(data => {
|
|
137
|
+
btn.disabled = false;
|
|
138
|
+
const row = btn.closest('tr');
|
|
139
|
+
if (data.enabled) {
|
|
140
|
+
btn.className = 'btn btn-sm btn-success toggle-btn';
|
|
141
|
+
btn.title = 'Enabled — click to disable';
|
|
142
|
+
btn.setAttribute('aria-pressed', 'true');
|
|
143
|
+
btn.setAttribute('aria-label', 'Enabled — click to disable');
|
|
144
|
+
const iconOn = btn.querySelector('i');
|
|
145
|
+
if (iconOn) iconOn.className = 'mdi mdi-check';
|
|
146
|
+
if (row) row.classList.remove('text-muted', 'opacity-50');
|
|
147
|
+
} else {
|
|
148
|
+
btn.className = 'btn btn-sm btn-secondary toggle-btn';
|
|
149
|
+
btn.title = 'Disabled — click to enable';
|
|
150
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
151
|
+
btn.setAttribute('aria-label', 'Disabled — click to enable');
|
|
152
|
+
const iconOff = btn.querySelector('i');
|
|
153
|
+
if (iconOff) iconOff.className = 'mdi mdi-minus';
|
|
154
|
+
if (row) row.classList.add('text-muted', 'opacity-50');
|
|
155
|
+
}
|
|
156
|
+
}).catch(() => {
|
|
157
|
+
btn.disabled = false;
|
|
158
|
+
window.location.reload();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
</script>
|
|
163
|
+
{{ block.super }}
|
|
164
|
+
{% endblock %}
|