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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/PKG-INFO +5 -5
  2. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/README.md +5 -5
  3. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/__init__.py +1 -1
  4. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/engine.py +32 -13
  5. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/filters.py +1 -1
  6. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/signals.py +2 -0
  7. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_api.py +14 -0
  8. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_device_rules.py +46 -0
  9. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine.py +12 -0
  10. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_engine_advanced.py +120 -7
  11. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_rules.py +24 -0
  12. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_signals.py +37 -0
  13. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_views.py +48 -0
  14. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/views.py +14 -5
  15. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/PKG-INFO +5 -5
  16. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/pyproject.toml +2 -2
  17. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/LICENSE +0 -0
  18. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/__init__.py +0 -0
  19. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/serializers.py +0 -0
  20. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/urls.py +0 -0
  21. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/api/views.py +0 -0
  22. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/forms.py +0 -0
  23. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/jobs.py +0 -0
  24. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
  25. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0002_regex_pattern_matching.py +0 -0
  26. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
  27. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
  28. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
  29. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0006_alter_interfacenamerule_options.py +0 -0
  30. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0007_alter_optional_fks_set_null.py +0 -0
  31. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0008_constraint_nonempty_pattern.py +0 -0
  32. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
  33. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0010_interfacenamerule_applies_to_device_interfaces.py +0 -0
  34. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0011_remove_interfacenamerule_interfacenamerule_module_type_mode_check_and_more.py +0 -0
  35. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/0012_remove_interfacenamerule_interfacenamerule_unique_exact_and_more.py +0 -0
  36. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
  37. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/models.py +0 -0
  38. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/navigation.py +0 -0
  39. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tables.py +0 -0
  40. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +0 -0
  41. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +0 -0
  42. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +0 -0
  43. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +0 -0
  44. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +0 -0
  45. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
  46. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
  47. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_misc.py +0 -0
  48. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/tests/test_regex.py +0 -0
  49. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/urls.py +0 -0
  50. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules/utils.py +0 -0
  51. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +0 -0
  52. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
  53. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
  54. {netbox_interface_name_rules-1.2.2 → netbox_interface_name_rules-1.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: NetBox plugin for automatic interface renaming when modules are installed
5
5
  Author: Marcin Zieba
6
6
  License-Expression: Apache-2.0
@@ -23,10 +23,6 @@ Dynamic: license-file
23
23
 
24
24
  # NetBox Interface Name Rules Plugin
25
25
 
26
- <p align="center">
27
- <img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="80"/>
28
- </p>
29
-
30
26
  [![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.2.3"
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
@@ -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,
@@ -53,7 +53,7 @@ class InterfaceNameRuleFilterSet(NetBoxModelFilterSet):
53
53
 
54
54
  def search(self, queryset, name, value):
55
55
  """Filter by pattern, template, description, or module type model name."""
56
- return queryset.filter(
56
+ return queryset.select_related("module_type").filter(
57
57
  Q(module_type_pattern__icontains=value)
58
58
  | Q(name_template__icontains=value)
59
59
  | Q(description__icontains=value)
@@ -37,6 +37,7 @@ def on_module_pre_save(sender, instance, **kwargs):
37
37
  old = sender.objects.filter(pk=instance.pk).values("module_type_id").first()
38
38
  instance._prev_module_type_id = old["module_type_id"] if old else None
39
39
  except Exception:
40
+ logger.warning("Failed to capture previous module_type_id for Module pk=%s", instance.pk, exc_info=True)
40
41
  instance._prev_module_type_id = None
41
42
 
42
43
 
@@ -141,6 +142,7 @@ def on_device_pre_save(sender, instance, **kwargs):
141
142
  instance._prev_virtual_chassis_id = old["virtual_chassis_id"] if old else None
142
143
  instance._prev_vc_position = old["vc_position"] if old else None
143
144
  except Exception:
145
+ logger.warning("Failed to capture previous VC state for Device pk=%s", instance.pk, exc_info=True)
144
146
  instance._prev_virtual_chassis_id = None
145
147
  instance._prev_vc_position = None
146
148
 
@@ -116,6 +116,20 @@ class InterfaceNameRuleAPITest(APITestCase):
116
116
  response = self.client.post(url, data, format="json", **self.header)
117
117
  self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
118
118
 
119
+ def test_create_with_both_module_type_and_pattern_fails(self):
120
+ """POST with both module_type FK and module_type_pattern set is rejected."""
121
+ url = self._get_list_url()
122
+ data = {
123
+ "module_type": self.module_type.pk,
124
+ "module_type_is_regex": True,
125
+ "module_type_pattern": "QSFP-.*",
126
+ "name_template": "port{bay_position}",
127
+ "channel_count": 0,
128
+ "channel_start": 0,
129
+ }
130
+ response = self.client.post(url, data, format="json", **self.header)
131
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
132
+
119
133
  def test_update_rule_to_regex(self):
120
134
  """PATCH a rule to switch it to regex mode."""
121
135
  rule = InterfaceNameRule.objects.create(
@@ -283,3 +283,49 @@ class ApplyDeviceInterfaceRulesModuleTypeTest(TestCase):
283
283
  self.assertEqual(result, 1)
284
284
  iface.refresh_from_db()
285
285
  self.assertEqual(iface.name, "Gi2/3")
286
+
287
+
288
+ class DeviceInterfaceEdgeCaseNamesTest(TestCase):
289
+ """Test _try_rename_device_interface with edge-case interface names."""
290
+
291
+ @classmethod
292
+ def setUpTestData(cls):
293
+ mfg = Manufacturer.objects.create(name="EdgeMfg", slug="edgemfg")
294
+ cls.device_type = DeviceType.objects.create(manufacturer=mfg, model="Edge-Dev", slug="edge-dev")
295
+ role = DeviceRole.objects.create(name="EdgeRole", slug="edgerole")
296
+ site = Site.objects.create(name="EdgeSite", slug="edgesite")
297
+ vc = VirtualChassis.objects.create(name="edge-vc")
298
+ cls.device = Device.objects.create(
299
+ name="edge-sw1",
300
+ device_type=cls.device_type,
301
+ role=role,
302
+ site=site,
303
+ virtual_chassis=vc,
304
+ vc_position=1,
305
+ )
306
+
307
+ def test_name_with_multiple_slashes(self):
308
+ """Interface name like FortyGigE1/2/3/4 extracts port='4'."""
309
+ InterfaceNameRule.objects.create(
310
+ applies_to_device_interfaces=True,
311
+ name_template="Fo{vc_position}/2/3/{port}",
312
+ )
313
+ iface = Interface.objects.create(
314
+ device=self.device, name="FortyGigE1/2/3/4", type="40gbase-x-qsfpp", module=None
315
+ )
316
+ result = apply_device_interface_rules(self.device)
317
+ self.assertEqual(result, 1)
318
+ iface.refresh_from_db()
319
+ self.assertEqual(iface.name, "Fo1/2/3/4")
320
+
321
+ def test_name_without_slash(self):
322
+ """Interface name without / uses full name as port."""
323
+ InterfaceNameRule.objects.create(
324
+ applies_to_device_interfaces=True,
325
+ name_template="eth{vc_position}-{port}",
326
+ )
327
+ iface = Interface.objects.create(device=self.device, name="eth0", type="1000base-t", module=None)
328
+ result = apply_device_interface_rules(self.device)
329
+ self.assertEqual(result, 1)
330
+ iface.refresh_from_db()
331
+ self.assertEqual(iface.name, "eth1-eth0")
@@ -101,3 +101,15 @@ class EvaluateNameTemplateTest(TestCase):
101
101
  {},
102
102
  )
103
103
  self.assertEqual(result, "port4")
104
+
105
+ def test_bay_position_num_non_numeric_uses_zero(self):
106
+ """When bay_position has no trailing digits, bay_position_num falls back to '0'."""
107
+ from netbox_interface_name_rules.engine import _resolve_bay_position
108
+
109
+ class FakeBay:
110
+ position = "abc"
111
+ name = "Bay abc"
112
+
113
+ bay_position, bay_position_num = _resolve_bay_position(FakeBay())
114
+ self.assertEqual(bay_position, "abc")
115
+ self.assertEqual(bay_position_num, "0")
@@ -19,7 +19,6 @@ from dcim.models import (
19
19
  Site,
20
20
  VirtualChassis,
21
21
  )
22
- from django.db import DatabaseError
23
22
  from django.test import TestCase
24
23
 
25
24
  from netbox_interface_name_rules.engine import (
@@ -265,9 +264,12 @@ class ApplyRuleToExistingTest(EngineAdvancedFixtures):
265
264
  enabled=False,
266
265
  )
267
266
  module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
268
- Interface.objects.create(device=self.device, module=module, name="0", type="10gbase-x-sfpp")
267
+ iface = Interface.objects.create(device=self.device, module=module, name="0", type="10gbase-x-sfpp")
269
268
  result = apply_rule_to_existing(rule)
270
269
  self.assertEqual(result, 0)
270
+ # Verify interface name is unchanged
271
+ iface.refresh_from_db()
272
+ self.assertEqual(iface.name, "0")
271
273
 
272
274
  def test_renames_matching_interface(self):
273
275
  """apply_rule_to_existing renames interfaces matching the rule."""
@@ -781,7 +783,7 @@ class EngineHasApplicableExceptionTest(TestCase):
781
783
 
782
784
  with patch(
783
785
  "netbox_interface_name_rules.engine.find_interfaces_for_rule",
784
- side_effect=RuntimeError("scan fail"),
786
+ side_effect=ValueError("scan fail"),
785
787
  ):
786
788
  result = has_applicable_interfaces(self.rule)
787
789
  self.assertFalse(result)
@@ -984,7 +986,9 @@ class EngineRenameDeviceInterfaceExceptionTest(TestCase):
984
986
  name_template="GigabitEthernet{vc_position}/{port}",
985
987
  )
986
988
  Interface.objects.create(device=self.device, name="Gi0/2", type="1000base-t")
987
- with patch("dcim.models.Interface.full_clean", side_effect=Exception("validation fail")):
989
+ from django.core.exceptions import ValidationError as DjangoValidationError
990
+
991
+ with patch("dcim.models.Interface.full_clean", side_effect=DjangoValidationError("validation fail")):
988
992
  apply_device_interface_rules(self.device)
989
993
  # Even with exception, no unhandled error
990
994
  rule.delete()
@@ -1217,7 +1221,7 @@ class ApplyRuleExceptionInLoopTest(EngineAdvancedFixtures):
1217
1221
  def _side_effect(*args, **kwargs):
1218
1222
  call_count[0] += 1
1219
1223
  if call_count[0] >= 2:
1220
- raise RuntimeError("forced failure on second call")
1224
+ raise ValueError("forced failure on second call")
1221
1225
  return _real_fn(*args, **kwargs)
1222
1226
 
1223
1227
  with patch("netbox_interface_name_rules.engine._apply_rule_to_interface", side_effect=_side_effect):
@@ -1249,7 +1253,7 @@ class ApplyRuleExceptionInLoopTest(EngineAdvancedFixtures):
1249
1253
  def _side_effect(*args, **kwargs):
1250
1254
  call_count[0] += 1
1251
1255
  if call_count[0] >= 2:
1252
- raise RuntimeError("forced channel failure")
1256
+ raise ValueError("forced channel failure")
1253
1257
  return _real_fn(*args, **kwargs)
1254
1258
 
1255
1259
  with patch("netbox_interface_name_rules.engine._apply_rule_to_interface", side_effect=_side_effect):
@@ -1400,7 +1404,9 @@ class DeviceInterfaceSaveExceptionTest(TestCase):
1400
1404
  )
1401
1405
  iface = Interface.objects.create(device=self.device, name="Gi0/1", type="1000base-t")
1402
1406
 
1403
- with patch.object(Interface, "save", side_effect=DatabaseError("disk full")):
1407
+ from django.db import IntegrityError
1408
+
1409
+ with patch.object(Interface, "save", side_effect=IntegrityError("disk full")):
1404
1410
  result = _try_rename_device_interface(rule, iface, "1", self.device, set())
1405
1411
 
1406
1412
  self.assertFalse(result)
@@ -1508,3 +1514,110 @@ class RegexTiebreakerTest(TestCase):
1508
1514
  self.assertLess(rule_short.pk, rule_long.pk)
1509
1515
  matched = find_matching_rule(module_type_specific, None, None)
1510
1516
  self.assertEqual(matched, rule_long)
1517
+
1518
+
1519
+ # ---------------------------------------------------------------------------
1520
+ # engine.py — _find_channel_base with empty interface list
1521
+ # ---------------------------------------------------------------------------
1522
+
1523
+
1524
+ class FindChannelBaseEmptyIfacesTest(TestCase):
1525
+ """Test _find_channel_base handles empty interface list gracefully."""
1526
+
1527
+ def test_empty_ifaces_returns_none(self):
1528
+ """_find_channel_base returns None when ifaces is empty."""
1529
+ rule = MagicMock()
1530
+ rule.name_template = "port{bay_position}:{channel}"
1531
+ rule.channel_start = 0
1532
+ variables = {"bay_position": "0", "bay_position_num": "0", "slot": "0"}
1533
+ result = _find_channel_base(rule, [], variables)
1534
+ self.assertIsNone(result)
1535
+
1536
+
1537
+ # ---------------------------------------------------------------------------
1538
+ # engine.py — find_interfaces_for_rule uses set for processed_pks
1539
+ # ---------------------------------------------------------------------------
1540
+
1541
+
1542
+ class FindInterfacesProcessedPksTest(EngineAdvancedFixtures):
1543
+ """Test find_interfaces_for_rule correctness with multiple modules."""
1544
+
1545
+ def test_multiple_modules_all_counted(self):
1546
+ """find_interfaces_for_rule processes all matching modules without duplication."""
1547
+ rule = InterfaceNameRule.objects.create(
1548
+ module_type=self.module_type,
1549
+ name_template="et-0/0/{bay_position}",
1550
+ )
1551
+ module0 = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
1552
+ module1 = Module.objects.create(device=self.device, module_bay=self.bay1, module_type=self.module_type)
1553
+ Interface.objects.create(device=self.device, module=module0, name="0", type="10gbase-x-sfpp")
1554
+ Interface.objects.create(device=self.device, module=module1, name="1", type="10gbase-x-sfpp")
1555
+
1556
+ results, total = find_interfaces_for_rule(rule)
1557
+ self.assertEqual(total, 2)
1558
+ self.assertEqual(len(results), 2)
1559
+ result_module_ids = {r["module"].pk for r in results}
1560
+ self.assertEqual(result_module_ids, {module0.pk, module1.pk})
1561
+
1562
+
1563
+ # ---------------------------------------------------------------------------
1564
+ # engine.py — breakout transaction rollback on mid-channel failure
1565
+ # ---------------------------------------------------------------------------
1566
+
1567
+
1568
+ class BreakoutTransactionRollbackTest(EngineAdvancedFixtures):
1569
+ """Test that _apply_rule_to_interface rolls back on mid-breakout failure."""
1570
+
1571
+ def test_partial_breakout_rolls_back(self):
1572
+ """If channel 2 fails validation, channels 0–1 are rolled back too."""
1573
+ rule = InterfaceNameRule.objects.create(
1574
+ module_type=self.module_type,
1575
+ name_template="Hu0/0/0/{bay_position}:{channel}",
1576
+ channel_count=4,
1577
+ channel_start=0,
1578
+ )
1579
+ module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
1580
+ iface = Interface.objects.create(device=self.device, module=module, name="0", type="100gbase-x-qsfp28")
1581
+
1582
+ original_full_clean = Interface.full_clean
1583
+ call_count = [0]
1584
+
1585
+ def failing_full_clean(self_iface, *args, **kwargs):
1586
+ call_count[0] += 1
1587
+ if call_count[0] == 3: # Fail on channel 2
1588
+ from django.core.exceptions import ValidationError
1589
+
1590
+ raise ValidationError("simulated failure")
1591
+ return original_full_clean(self_iface, *args, **kwargs)
1592
+
1593
+ from netbox_interface_name_rules.engine import _apply_rule_to_interface
1594
+
1595
+ variables = build_variables(module.module_bay, device=module.device)
1596
+ variables["base"] = iface.name
1597
+
1598
+ from django.core.exceptions import ValidationError
1599
+
1600
+ with patch.object(Interface, "full_clean", failing_full_clean):
1601
+ with self.assertRaises(ValidationError):
1602
+ _apply_rule_to_interface(rule, iface, variables, module)
1603
+
1604
+ # Transaction rolled back — only the original interface remains
1605
+ iface_names = list(Interface.objects.filter(module=module).values_list("name", flat=True))
1606
+ self.assertEqual(iface_names, ["0"])
1607
+
1608
+
1609
+ # ---------------------------------------------------------------------------
1610
+ # engine.py — _get_raw_interface_names with no templates
1611
+ # ---------------------------------------------------------------------------
1612
+
1613
+
1614
+ class GetRawInterfaceNamesNoTemplatesTest(EngineAdvancedFixtures):
1615
+ """Test _get_raw_interface_names when module_type has no InterfaceTemplate entries."""
1616
+
1617
+ def test_no_templates_returns_empty_set(self):
1618
+ """_get_raw_interface_names returns empty set when module_type has no templates."""
1619
+ from netbox_interface_name_rules.engine import _get_raw_interface_names
1620
+
1621
+ module = Module.objects.create(device=self.device, module_bay=self.bay0, module_type=self.module_type)
1622
+ result = _get_raw_interface_names(module)
1623
+ self.assertEqual(result, set())
@@ -122,6 +122,30 @@ class FindMatchingRuleTest(TestCase):
122
122
  result = find_matching_rule(self.module_type, None, None)
123
123
  self.assertIsNone(result)
124
124
 
125
+ def test_platform_none_does_not_match_platform_scoped_rule(self):
126
+ """With platform=None, a platform-scoped rule is NOT matched."""
127
+ InterfaceNameRule.objects.create(
128
+ module_type=self.module_type,
129
+ platform=self.platform,
130
+ name_template="platform-only{bay_position}",
131
+ )
132
+ result = find_matching_rule(self.module_type, None, None, platform=None)
133
+ self.assertIsNone(result)
134
+
135
+ def test_platform_none_falls_back_to_unscoped_rule(self):
136
+ """With platform=None, an unscoped rule is preferred over a platform-scoped one."""
137
+ InterfaceNameRule.objects.create(
138
+ module_type=self.module_type,
139
+ platform=self.platform,
140
+ name_template="platform{bay_position}",
141
+ )
142
+ unscoped = InterfaceNameRule.objects.create(
143
+ module_type=self.module_type,
144
+ name_template="generic{bay_position}",
145
+ )
146
+ result = find_matching_rule(self.module_type, None, None, platform=None)
147
+ self.assertEqual(result, unscoped)
148
+
125
149
 
126
150
  class BuildVariablesTest(TestCase):
127
151
  """Test build_variables from module bay context."""
@@ -504,3 +504,40 @@ class ModuleDeletionCascadeTest(TestCase):
504
504
 
505
505
  # Interface was cascade-deleted with the module
506
506
  self.assertFalse(Interface.objects.filter(pk=iface_pk).exists())
507
+
508
+
509
+ class ModulePreSaveExceptionLoggingTest(TestCase):
510
+ """Test on_module_pre_save logs exceptions instead of silently swallowing them."""
511
+
512
+ @classmethod
513
+ def setUpTestData(cls):
514
+ mfg = Manufacturer.objects.create(name="PreSaveLogMfg", slug="presavelogmfg")
515
+ cls.device_type = DeviceType.objects.create(manufacturer=mfg, model="PSL-Dev", slug="psl-dev")
516
+ cls.module_type = ModuleType.objects.create(manufacturer=mfg, model="PSL-SFP", part_number="PSL-SFP")
517
+ ModuleBayTemplate.objects.create(device_type=cls.device_type, name="Bay 0", position="0")
518
+ role = DeviceRole.objects.create(name="PSLRole", slug="pslrole")
519
+ site = Site.objects.create(name="PSLSite", slug="pslsite")
520
+ cls.device = Device.objects.create(name="psl-dev-01", device_type=cls.device_type, role=role, site=site)
521
+ cls.bay = ModuleBay.objects.get(device=cls.device, name="Bay 0")
522
+
523
+ def test_pre_save_db_error_logs_warning(self):
524
+ """on_module_pre_save logs a warning when DB lookup fails."""
525
+ from django.db import DatabaseError
526
+
527
+ module = Module.objects.create(device=self.device, module_bay=self.bay, module_type=self.module_type)
528
+ with patch.object(Module.objects, "filter", side_effect=DatabaseError("db error")):
529
+ with self.assertLogs("netbox_interface_name_rules", level="WARNING") as cm:
530
+ on_module_pre_save(Module, module)
531
+ self.assertIsNone(module._prev_module_type_id)
532
+ self.assertTrue(any("db error" in msg for msg in cm.output))
533
+
534
+ def test_device_pre_save_db_error_logs_warning(self):
535
+ """on_device_pre_save logs a warning when DB lookup fails."""
536
+ from django.db import DatabaseError
537
+
538
+ with patch.object(Device.objects, "filter", side_effect=DatabaseError("db error")):
539
+ with self.assertLogs("netbox_interface_name_rules", level="WARNING") as cm:
540
+ on_device_pre_save(Device, self.device)
541
+ self.assertIsNone(self.device._prev_virtual_chassis_id)
542
+ self.assertIsNone(self.device._prev_vc_position)
543
+ self.assertTrue(any("db error" in msg for msg in cm.output))
@@ -369,6 +369,54 @@ class RuleTestViewGetWithRuleIdTest(ViewTestBase2):
369
369
  self.assertEqual(response.status_code, 200)
370
370
 
371
371
 
372
+ class RuleTestViewPermissionTest(ViewTestBase2):
373
+ """Test RuleTestView permission checks for unprivileged users."""
374
+
375
+ def _url(self):
376
+ return reverse("plugins:netbox_interface_name_rules:interfacenamerule_test")
377
+
378
+ def test_get_no_permission_raises_403(self):
379
+ """GET to rule test view without add_interfacenamerule permission returns 403."""
380
+ User.objects.create_user(username="noperm_test_get", password=TEST_PASSWORD)
381
+ self.client.login(username="noperm_test_get", password=TEST_PASSWORD)
382
+ response = self.client.get(self._url())
383
+ self.assertEqual(response.status_code, 403)
384
+
385
+ def test_get_with_rule_id_no_permission_raises_403(self):
386
+ """GET with ?rule_id= without add_interfacenamerule permission returns 403."""
387
+ User.objects.create_user(username="noperm_test_get2", password=TEST_PASSWORD)
388
+ self.client.login(username="noperm_test_get2", password=TEST_PASSWORD)
389
+ response = self.client.get(self._url() + f"?rule_id={self.rule.pk}")
390
+ self.assertEqual(response.status_code, 403)
391
+
392
+ def test_post_no_permission_raises_403(self):
393
+ """POST to rule test view without add_interfacenamerule permission returns 403."""
394
+ User.objects.create_user(username="noperm_test_post", password=TEST_PASSWORD)
395
+ self.client.login(username="noperm_test_post", password=TEST_PASSWORD)
396
+ data = {
397
+ "name_template": "et-0/0/{bay_position}",
398
+ "channel_count": "0",
399
+ "channel_start": "0",
400
+ }
401
+ response = self.client.post(self._url(), data)
402
+ self.assertEqual(response.status_code, 403)
403
+
404
+
405
+ class RuleApplyDetailViewGetPermissionTest(ViewTestBase2):
406
+ """Test RuleApplyDetailView.get() permission check for unprivileged users."""
407
+
408
+ def test_get_no_change_permission_raises_403(self):
409
+ """GET apply detail without dcim.change_interface permission returns 403."""
410
+ User.objects.create_user(username="noperm_apply_get", password=TEST_PASSWORD)
411
+ self.client.login(username="noperm_apply_get", password=TEST_PASSWORD)
412
+ url = reverse(
413
+ "plugins:netbox_interface_name_rules:interfacenamerule_apply_detail",
414
+ kwargs={"pk": self.rule.pk},
415
+ )
416
+ response = self.client.get(url)
417
+ self.assertEqual(response.status_code, 403)
418
+
419
+
372
420
  class RuleApplyDetailViewPostTest(ViewTestBase2):
373
421
  """Test RuleApplyDetailView.post() — foreground apply, background job, no permission."""
374
422
 
@@ -10,6 +10,7 @@ from django.core.exceptions import PermissionDenied
10
10
  from django.http import JsonResponse
11
11
  from django.shortcuts import get_object_or_404, redirect, render
12
12
  from django.urls import reverse
13
+ from django.utils.http import url_has_allowed_host_and_scheme
13
14
  from django.views import View
14
15
  from netbox.views import generic
15
16
  from utilities.views import ConditionalLoginRequiredMixin, register_model_view
@@ -115,7 +116,7 @@ class InterfaceNameRuleDuplicateView(ConditionalLoginRequiredMixin, View):
115
116
  """Redirect to the add view pre-populated with fields cloned from rule pk."""
116
117
  from utilities.querydict import prepare_cloned_fields
117
118
 
118
- rule = get_object_or_404(InterfaceNameRule.objects.all(), pk=pk)
119
+ rule = get_object_or_404(InterfaceNameRule, pk=pk)
119
120
  params = prepare_cloned_fields(rule)
120
121
  url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add")
121
122
  return redirect(f"{url}?{params.urlencode()}")
@@ -126,8 +127,13 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
126
127
 
127
128
  template_name = "netbox_interface_name_rules/rule_test.html"
128
129
 
130
+ def _check_permission(self, request):
131
+ if not request.user.has_perm("netbox_interface_name_rules.add_interfacenamerule"):
132
+ raise PermissionDenied
133
+
129
134
  def get(self, request):
130
135
  """Render the test form, pre-populated from rule_id query param if given."""
136
+ self._check_permission(request)
131
137
  initial = {}
132
138
  loaded_rule = None
133
139
  rule_id = request.GET.get("rule_id")
@@ -153,6 +159,7 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
153
159
 
154
160
  def post(self, request):
155
161
  """Evaluate the submitted template and return a preview or redirect to save."""
162
+ self._check_permission(request)
156
163
  form = RuleTestForm(request.POST)
157
164
  preview_results = None
158
165
  db_preview = None
@@ -347,10 +354,15 @@ class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
347
354
 
348
355
  template_name = "netbox_interface_name_rules/rule_apply_detail.html"
349
356
 
357
+ def _check_permission(self, request):
358
+ if not request.user.has_perm("dcim.change_interface"):
359
+ raise PermissionDenied
360
+
350
361
  def get(self, request, pk):
351
362
  """Render a preview of all interfaces that would be renamed by this rule."""
352
363
  from .engine import find_interfaces_for_rule
353
364
 
365
+ self._check_permission(request)
354
366
  rule = get_object_or_404(InterfaceNameRule, pk=pk)
355
367
  try:
356
368
  preview, total_checked = find_interfaces_for_rule(rule, limit=APPLY_BATCH_LIMIT)
@@ -379,8 +391,7 @@ class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
379
391
  """Apply the rule (foreground batch or background job) and redirect back."""
380
392
  from .engine import apply_rule_to_existing
381
393
 
382
- if not request.user.has_perm("dcim.change_interface"):
383
- raise PermissionDenied
394
+ self._check_permission(request)
384
395
 
385
396
  rule = get_object_or_404(InterfaceNameRule, pk=pk)
386
397
  action = request.POST.get("action", "apply")
@@ -436,8 +447,6 @@ class RuleToggleView(generic.ObjectView):
436
447
  return JsonResponse({"enabled": rule.enabled, "pk": pk})
437
448
  state = "enabled" if rule.enabled else "disabled"
438
449
  messages.success(request, f"Rule '{rule}' {state}.")
439
- from django.utils.http import url_has_allowed_host_and_scheme
440
-
441
450
  referer = request.META.get("HTTP_REFERER", "")
442
451
  if referer and url_has_allowed_host_and_scheme(
443
452
  referer, allowed_hosts={request.get_host()}, require_https=request.is_secure()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: NetBox plugin for automatic interface renaming when modules are installed
5
5
  Author: Marcin Zieba
6
6
  License-Expression: Apache-2.0
@@ -23,10 +23,6 @@ Dynamic: license-file
23
23
 
24
24
  # NetBox Interface Name Rules Plugin
25
25
 
26
- <p align="center">
27
- <img src="https://raw.githubusercontent.com/marcinpsk/netbox-InterfaceNameRules-plugin/main/docs/icon.svg" alt="NetBox Interface Name Rules" width="80"/>
28
- </p>
29
-
30
26
  [![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"/>
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "netbox-interface-name-rules"
9
- version = "1.2.2"
9
+ version = "1.2.3"
10
10
  description = "NetBox plugin for automatic interface renaming when modules are installed"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.12.0"
@@ -57,7 +57,7 @@ dev = [
57
57
  "pytest-django",
58
58
  "pytest-cov>=6.0",
59
59
  "python-semantic-release",
60
- "django>=5.1,<6.0",
60
+ "django>=5.1,<7.0",
61
61
  ]
62
62
  docs = [
63
63
  "mkdocs>=1,<2",