netbox-interface-name-rules 1.2.2__tar.gz → 1.3.0__tar.gz

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