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.
Files changed (57) hide show
  1. {netbox_interface_name_rules-1.2.1/netbox_interface_name_rules.egg-info → netbox_interface_name_rules-1.2.3}/PKG-INFO +29 -1
  2. netbox_interface_name_rules-1.2.1/PKG-INFO → netbox_interface_name_rules-1.2.3/README.md +19 -14
  3. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/__init__.py +3 -1
  4. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/serializers.py +6 -7
  5. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/engine.py +33 -15
  6. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/filters.py +1 -1
  7. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/models.py +0 -1
  8. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/signals.py +2 -0
  9. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +115 -0
  10. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +164 -0
  11. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +180 -0
  12. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +210 -0
  13. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +258 -0
  14. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_api.py +14 -0
  15. {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
  16. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine.py +12 -0
  17. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_engine_advanced.py +1623 -0
  18. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_misc.py +307 -1
  19. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_regex.py +1 -1
  20. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_rules.py +24 -0
  21. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_signals.py +543 -0
  22. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/tests/test_views.py +662 -0
  23. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/views.py +14 -5
  24. 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
  25. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +5 -0
  26. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/pyproject.toml +30 -6
  27. netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_engine_advanced.py +0 -680
  28. netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_signals.py +0 -258
  29. netbox_interface_name_rules-1.2.1/netbox_interface_name_rules/tests/test_views.py +0 -328
  30. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/LICENSE +0 -0
  31. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/__init__.py +0 -0
  32. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/urls.py +0 -0
  33. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/views.py +0 -0
  34. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/forms.py +1 -1
  35. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/jobs.py +0 -0
  36. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
  37. {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
  38. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
  39. {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
  40. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
  41. {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
  42. {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
  43. {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
  44. {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
  45. {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
  46. {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
  47. {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
  48. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
  49. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/navigation.py +0 -0
  50. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tables.py +0 -0
  51. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
  52. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
  53. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/urls.py +0 -0
  54. {netbox_interface_name_rules-1.2.1 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/utils.py +0 -0
  55. {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
  56. {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
  57. {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.1
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
  [![Python](https://img.shields.io/pypi/pyversions/netbox-interface-name-rules)](https://pypi.org/project/netbox-interface-name-rules/)
23
32
  [![NetBox](https://img.shields.io/badge/NetBox-%E2%89%A54.2.0-blue)](https://github.com/netbox-community/netbox)
24
33
  [![Contributors](https://img.shields.io/github/contributors/marcinpsk/netbox-InterfaceNameRules-plugin)](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
34
+ [![REUSE status](https://api.reuse.software/badge/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)](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
  [![PyPI](https://img.shields.io/pypi/v/netbox-interface-name-rules)](https://pypi.org/project/netbox-interface-name-rules/)
@@ -22,6 +8,7 @@ Dynamic: license-file
22
8
  [![Python](https://img.shields.io/pypi/pyversions/netbox-interface-name-rules)](https://pypi.org/project/netbox-interface-name-rules/)
23
9
  [![NetBox](https://img.shields.io/badge/NetBox-%E2%89%A54.2.0-blue)](https://github.com/netbox-community/netbox)
24
10
  [![Contributors](https://img.shields.io/github/contributors/marcinpsk/netbox-InterfaceNameRules-plugin)](https://github.com/marcinpsk/netbox-InterfaceNameRules-plugin/graphs/contributors)
11
+ [![REUSE status](https://api.reuse.software/badge/github.com/marcinpsk/netbox-InterfaceNameRules-plugin)](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.1"
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.db import transaction
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 Exception:
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 Exception:
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 Exception:
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 bay_position
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 Exception:
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
- processed_pks = []
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.append(module.pk)
712
+ processed_pks.add(module.pk)
701
713
  variables = build_variables(module.module_bay, device=module.device)
702
- ifaces = list(Interface.objects.filter(module=module).order_by("name"))
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 Exception:
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 list(Interface.objects.filter(module=module).order_by("name")):
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 Exception:
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
- result = re.sub(r"\{([^}]+)\}", _eval_expr, result)
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)
@@ -9,7 +9,6 @@ from django.urls import reverse
9
9
  from netbox.models import NetBoxModel
10
10
  from taggit.managers import TaggableManager
11
11
 
12
-
13
12
  _REDOS_PATTERN = re.compile(r"(\+\*|\*\+|\?\?|\)\s*[\+\*\?]\s*[\+\*\?]|\)\s*\{[^{}]+\}\s*[\+\*\?])")
14
13
 
15
14
 
@@ -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+&nbsp;vs max ≤&nbsp;955).
33
+ Among regex rules, adding scope fields raises the score:
34
+ <code>parent_module_type</code>&nbsp;+4×100,
35
+ <code>device_type</code>&nbsp;+2×100,
36
+ <code>platform</code>&nbsp;+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" &rarr; "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" &rarr; "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 &rarr;
61
+ <code>GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}</code></li>
62
+ <li><strong>Breakout:</strong> QSFP-4X10G-LR &rarr; <code>{base}:{channel}</code>
63
+ (Channels=4, Ch. Start=0)</li>
64
+ <li><strong>Platform naming:</strong> QSFP-100G-LR4 on ACX7024 &rarr;
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 &rarr;
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 &rarr;
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 &rarr;
71
+ <code>GigabitEthernet{vc_position}/0/{port}</code> &rarr; 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 &rarr;
74
+ <code>ge-{vc_position}/0/{port}</code> &rarr; 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 &rarr;
77
+ <code>Ethernet{vc_position}/{port}</code> &rarr; 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 %}