netbox-interface-name-rules 1.2.3__tar.gz → 1.3.1__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.3 → netbox_interface_name_rules-1.3.1}/PKG-INFO +1 -1
  2. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/__init__.py +1 -1
  3. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/engine.py +1 -2
  4. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0003_constraints.py +1 -1
  5. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0008_constraint_nonempty_pattern.py +1 -1
  6. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/models.py +43 -1
  7. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tables.py +1 -1
  8. netbox_interface_name_rules-1.3.1/netbox_interface_name_rules/templates/netbox_interface_name_rules/buttons/export_yaml_only.html +33 -0
  9. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +0 -17
  10. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_misc.py +71 -40
  11. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_views.py +198 -0
  12. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/views.py +103 -66
  13. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/PKG-INFO +1 -1
  14. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/SOURCES.txt +1 -1
  15. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/pyproject.toml +1 -1
  16. netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/utils.py +0 -17
  17. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/LICENSE +0 -0
  18. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/README.md +0 -0
  19. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/__init__.py +0 -0
  20. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/serializers.py +0 -0
  21. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/urls.py +0 -0
  22. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/views.py +0 -0
  23. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/filters.py +0 -0
  24. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/forms.py +0 -0
  25. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/jobs.py +0 -0
  26. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
  27. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0002_regex_pattern_matching.py +0 -0
  28. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
  29. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
  30. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0006_alter_interfacenamerule_options.py +0 -0
  31. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0007_alter_optional_fks_set_null.py +0 -0
  32. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
  33. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0010_interfacenamerule_applies_to_device_interfaces.py +0 -0
  34. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/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.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0012_remove_interfacenamerule_interfacenamerule_unique_exact_and_more.py +0 -0
  36. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/__init__.py +0 -0
  37. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/navigation.py +0 -0
  38. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/signals.py +0 -0
  39. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +0 -0
  40. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +0 -0
  41. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +0 -0
  42. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +0 -0
  43. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/__init__.py +0 -0
  44. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_api.py +0 -0
  45. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_device_rules.py +0 -0
  46. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
  47. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_engine.py +0 -0
  48. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_engine_advanced.py +0 -0
  49. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_regex.py +0 -0
  50. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_rules.py +0 -0
  51. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_signals.py +0 -0
  52. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/urls.py +0 -0
  53. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
  54. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
  55. {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.2.3
3
+ Version: 1.3.1
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
@@ -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.3"
5
+ __version__ = "1.3.1"
6
6
 
7
7
 
8
8
  class InterfaceNameRulesConfig(PluginConfig):
@@ -561,8 +561,7 @@ def has_applicable_interfaces(rule) -> bool:
561
561
  Calls find_interfaces_for_rule(limit=1) to determine if any currently installed
562
562
  interface would receive a new name. Returns False when:
563
563
  - no matching modules/interfaces are installed, OR
564
- - all matching interfaces are already correctly named (e.g. NetBox resolved
565
- {module_path} at install time, making the rule a no-op for existing interfaces).
564
+ - all matching interfaces are already correctly named.
566
565
 
567
566
  This is more expensive than a plain EXISTS query but ensures the Applicable
568
567
  column in the Apply Rules list accurately reflects "would something change?"
@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
15
15
  migrations.AddConstraint(
16
16
  model_name="interfacenamerule",
17
17
  constraint=models.CheckConstraint(
18
- check=(
18
+ condition=(
19
19
  models.Q(module_type_is_regex=True, module_type__isnull=True)
20
20
  | models.Q(module_type_is_regex=False, module_type__isnull=False)
21
21
  ),
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
18
18
  migrations.AddConstraint(
19
19
  model_name="interfacenamerule",
20
20
  constraint=models.CheckConstraint(
21
- check=(
21
+ condition=(
22
22
  models.Q(module_type_is_regex=True, module_type__isnull=True, module_type_pattern__gt="")
23
23
  | models.Q(module_type_is_regex=False, module_type__isnull=False)
24
24
  ),
@@ -238,7 +238,7 @@ class InterfaceNameRule(NetBoxModel):
238
238
  ordering = ["module_type__model", "pk"]
239
239
  constraints = [
240
240
  models.CheckConstraint(
241
- check=(
241
+ condition=(
242
242
  models.Q(applies_to_device_interfaces=True, module_type__isnull=True)
243
243
  | models.Q(
244
244
  applies_to_device_interfaces=False,
@@ -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)
@@ -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>
@@ -1,6 +1,6 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
- """Tests for utils, jobs, model properties, and API serializer edge-cases."""
3
+ """Tests for jobs, model properties, and API serializer edge-cases."""
4
4
 
5
5
  from unittest.mock import MagicMock, patch
6
6
 
@@ -9,21 +9,6 @@ from django.core.exceptions import ValidationError
9
9
  from django.test import TestCase
10
10
 
11
11
  from netbox_interface_name_rules.models import InterfaceNameRule
12
- from netbox_interface_name_rules.utils import supports_module_path
13
-
14
- # ---------------------------------------------------------------------------
15
- # utils.py
16
- # ---------------------------------------------------------------------------
17
-
18
-
19
- class SupportModulePathTest(TestCase):
20
- """Test the supports_module_path feature-detection helper."""
21
-
22
- def test_returns_bool(self):
23
- """supports_module_path() returns a boolean (True or False)."""
24
- result = supports_module_path()
25
- self.assertIsInstance(result, bool)
26
-
27
12
 
28
13
  # ---------------------------------------------------------------------------
29
14
  # jobs.py
@@ -742,31 +727,77 @@ class JobRunSuccessAndExceptionTest(TestCase):
742
727
 
743
728
 
744
729
  # ---------------------------------------------------------------------------
745
- # utils.pysupports_module_path ImportError path (lines 16-17)
730
+ # Model csv_headers / to_csv() regression: KeyError 'Ch' on CSV import
746
731
  # ---------------------------------------------------------------------------
747
732
 
748
733
 
749
- class UtilsModulePathFalseTest(TestCase):
750
- """Test supports_module_path() returns False when MODULE_PATH_TOKEN is missing."""
734
+ class ModelCSVExportTest(TestCase):
735
+ """Test that InterfaceNameRule exposes csv_headers and to_csv() for round-trip CSV."""
751
736
 
752
- def test_returns_false_when_token_missing(self):
753
- """supports_module_path() returns False when MODULE_PATH_TOKEN is absent.
754
-
755
- Temporarily removes the attribute from dcim.constants (if present) to
756
- trigger the ImportError branch in supports_module_path, then restores it.
757
- Asserts False regardless of whether the attribute existed beforehand.
758
- """
759
- import dcim.constants as dc
760
-
761
- from netbox_interface_name_rules.utils import supports_module_path
762
-
763
- had_attr = hasattr(dc, "MODULE_PATH_TOKEN")
764
- original = getattr(dc, "MODULE_PATH_TOKEN", None)
765
- try:
766
- if had_attr:
767
- delattr(dc, "MODULE_PATH_TOKEN")
768
- result = supports_module_path()
769
- self.assertFalse(result)
770
- finally:
771
- if had_attr:
772
- dc.MODULE_PATH_TOKEN = original
737
+ @classmethod
738
+ def setUpTestData(cls):
739
+ manufacturer = Manufacturer.objects.create(name="CSVMfg", slug="csvmfg")
740
+ cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model="CSV-SFP", part_number="CSV-SFP")
741
+ cls.rule = InterfaceNameRule.objects.create(
742
+ module_type=cls.module_type,
743
+ name_template="et-0/0/{bay_position}",
744
+ channel_count=4,
745
+ channel_start=0,
746
+ description="CSV test rule",
747
+ )
748
+ cls.regex_rule = InterfaceNameRule.objects.create(
749
+ module_type_is_regex=True,
750
+ module_type_pattern="QSFP-.*",
751
+ name_template="{base}/{channel}",
752
+ channel_count=4,
753
+ channel_start=1,
754
+ description="Regex CSV rule",
755
+ )
756
+
757
+ def test_csv_headers_attribute_exists(self):
758
+ """InterfaceNameRule.csv_headers class attribute must exist."""
759
+ self.assertTrue(hasattr(InterfaceNameRule, "csv_headers"), "InterfaceNameRule.csv_headers missing")
760
+
761
+ def test_csv_headers_is_list_or_tuple(self):
762
+ """csv_headers must be a list or tuple of strings."""
763
+ self.assertIsInstance(InterfaceNameRule.csv_headers, (list, tuple))
764
+
765
+ def test_csv_headers_matches_import_form_fields(self):
766
+ """csv_headers must exactly match InterfaceNameRuleImportForm.Meta.fields."""
767
+ from netbox_interface_name_rules.forms import InterfaceNameRuleImportForm
768
+
769
+ form_fields = list(InterfaceNameRuleImportForm.Meta.fields)
770
+ self.assertEqual(list(InterfaceNameRule.csv_headers), form_fields)
771
+
772
+ def test_to_csv_method_exists(self):
773
+ """InterfaceNameRule instances must have a to_csv() method."""
774
+ self.assertTrue(callable(getattr(self.rule, "to_csv", None)), "InterfaceNameRule.to_csv() missing")
775
+
776
+ def test_to_csv_returns_sequence(self):
777
+ """to_csv() must return a tuple or list."""
778
+ result = self.rule.to_csv()
779
+ self.assertIsInstance(result, (tuple, list))
780
+
781
+ def test_to_csv_length_matches_csv_headers(self):
782
+ """to_csv() must return exactly len(csv_headers) values."""
783
+ result = self.rule.to_csv()
784
+ self.assertEqual(len(result), len(InterfaceNameRule.csv_headers))
785
+
786
+ def test_to_csv_exact_rule_module_type(self):
787
+ """to_csv() for exact rule must include the module_type model string."""
788
+ result = self.rule.to_csv()
789
+ values = list(result)
790
+ idx = list(InterfaceNameRule.csv_headers).index("module_type")
791
+ self.assertEqual(values[idx], "CSV-SFP")
792
+
793
+ def test_to_csv_regex_rule_no_module_type(self):
794
+ """to_csv() for regex rule must have empty string for module_type."""
795
+ result = self.regex_rule.to_csv()
796
+ values = list(result)
797
+ idx = list(InterfaceNameRule.csv_headers).index("module_type")
798
+ self.assertEqual(values[idx], "")
799
+
800
+ def test_to_csv_no_dots_in_headers(self):
801
+ """csv_headers must not contain dots (regression guard for KeyError 'Ch' bug)."""
802
+ for header in InterfaceNameRule.csv_headers:
803
+ self.assertNotIn(".", header, f"csv_headers entry '{header}' contains a dot — would break import")
@@ -402,6 +402,55 @@ class RuleTestViewPermissionTest(ViewTestBase2):
402
402
  self.assertEqual(response.status_code, 403)
403
403
 
404
404
 
405
+ class RuleTestViewAddOnlyPermissionTest(ViewTestBase2):
406
+ """Test RuleTestView behaviour for users with add but not view permission."""
407
+
408
+ def _url(self):
409
+ return reverse("plugins:netbox_interface_name_rules:interfacenamerule_test")
410
+
411
+ def _create_add_only_user(self):
412
+ from users.models import ObjectPermission
413
+
414
+ user = User.objects.create_user(username="add_only_tester", password=TEST_PASSWORD)
415
+ # object_types targets ContentType on older NetBox and ObjectType on newer NetBox
416
+ obj_type_model = ObjectPermission._meta.get_field("object_types").related_model
417
+ ct = obj_type_model.objects.get_for_model(InterfaceNameRule)
418
+ obj_perm = ObjectPermission.objects.create(name="add_only_rule", actions=["add"])
419
+ obj_perm.object_types.add(ct)
420
+ obj_perm.users.add(user)
421
+ return user
422
+
423
+ def test_get_with_rule_id_add_only_shows_blank_form_with_warning(self):
424
+ """GET with ?rule_id= when user has add but not view permission returns blank form."""
425
+ user = self._create_add_only_user()
426
+ self.client.force_login(user)
427
+ response = self.client.get(self._url() + f"?rule_id={self.rule.pk}")
428
+ self.assertEqual(response.status_code, 200)
429
+ form = response.context["form"]
430
+ self.assertFalse(form.initial, "Form should have no initial data for add-only user")
431
+ self.assertIsNone(response.context.get("loaded_rule"))
432
+ from django.contrib.messages import get_messages
433
+
434
+ msgs = [str(m) for m in get_messages(response.wsgi_request)]
435
+ self.assertTrue(any("permission" in m.lower() for m in msgs))
436
+
437
+ def test_save_rule_add_only_skips_duplicate_check(self):
438
+ """POST save_rule with add-only permission skips duplicate detection, redirects to add."""
439
+ user = self._create_add_only_user()
440
+ self.client.force_login(user)
441
+ add_url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add")
442
+ data = {
443
+ "name_template": "ge-0/0/{bay_position}",
444
+ "channel_count": "0",
445
+ "channel_start": "0",
446
+ "module_type": str(self.module_type.pk),
447
+ "action": "save_rule",
448
+ }
449
+ response = self.client.post(self._url(), data)
450
+ self.assertEqual(response.status_code, 302)
451
+ self.assertIn(add_url, response["Location"])
452
+
453
+
405
454
  class RuleApplyDetailViewGetPermissionTest(ViewTestBase2):
406
455
  """Test RuleApplyDetailView.get() permission check for unprivileged users."""
407
456
 
@@ -660,3 +709,152 @@ class RuleApplyDetailViewBackgroundJobSuccessTest(ViewTestBase2):
660
709
  success_msgs = [m for m in msgs if m.level == SUCCESS]
661
710
  self.assertTrue(success_msgs, "Expected a success-level message but none found")
662
711
  self.assertTrue(any("42" in str(m) for m in success_msgs))
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # BulkImportCSVTest — regression: KeyError 'Ch' on CSV import round-trip
716
+ # ---------------------------------------------------------------------------
717
+
718
+
719
+ class BulkImportCSVTest(ViewTestBase):
720
+ """CSV import round-trip tests; guards against the KeyError 'Ch' regression."""
721
+
722
+ def _csv_from_rule(self, rule):
723
+ """Build a one-row CSV string from a rule's csv_headers and to_csv()."""
724
+ import csv
725
+ import io
726
+
727
+ buf = io.StringIO()
728
+ writer = csv.writer(buf)
729
+ writer.writerow(InterfaceNameRule.csv_headers)
730
+ writer.writerow(rule.to_csv())
731
+ return buf.getvalue()
732
+
733
+ def test_csv_import_does_not_raise_key_error(self):
734
+ """POST to import with a properly exported CSV must not produce a KeyError.
735
+
736
+ This is the regression test for the 'Ch' KeyError: when using csv_headers
737
+ and to_csv() the column names are plain field names with no dots.
738
+ """
739
+ self.client.force_login(self.superuser)
740
+ csv_data = self._csv_from_rule(self.rule)
741
+ url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_bulk_import")
742
+ # If a KeyError is raised the view returns 500; assert it doesn't.
743
+ try:
744
+ response = self.client.post(url, {"data": csv_data, "format": "csv", "csv_delimiter": "auto"})
745
+ except KeyError as exc:
746
+ self.fail(f"CSV import raised KeyError: {exc!r}")
747
+ self.assertNotEqual(response.status_code, 500, "CSV import returned a 500 error")
748
+
749
+ def test_csv_round_trip_creates_rule(self):
750
+ """CSV exported from a fresh rule can be imported to create a new rule."""
751
+ import csv
752
+ import io
753
+ import uuid
754
+
755
+ self.client.force_login(self.superuser)
756
+ # Build a unique ModuleType so the imported row doesn't collide with fixtures.
757
+ unique_model = f"ROUND-TRIP-{uuid.uuid4().hex[:8]}"
758
+ mt = ModuleType.objects.create(
759
+ manufacturer=self.module_type.manufacturer,
760
+ model=unique_model,
761
+ part_number=unique_model,
762
+ )
763
+ buf = io.StringIO()
764
+ writer = csv.writer(buf)
765
+ writer.writerow(InterfaceNameRule.csv_headers)
766
+ writer.writerow(
767
+ [
768
+ mt.model, # module_type
769
+ "", # module_type_pattern
770
+ False, # module_type_is_regex
771
+ "", # parent_module_type
772
+ "", # device_type
773
+ "", # platform
774
+ "et-0/0/{bay_position}", # name_template
775
+ 0, # channel_count
776
+ 0, # channel_start
777
+ "round-trip test", # description
778
+ True, # enabled
779
+ False, # applies_to_device_interfaces
780
+ ]
781
+ )
782
+ csv_data = buf.getvalue()
783
+
784
+ url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_bulk_import")
785
+ before_count = InterfaceNameRule.objects.count()
786
+ response = self.client.post(url, {"data": csv_data, "format": "csv", "csv_delimiter": "auto"})
787
+ # A successful import redirects (302); failure re-renders the form (200).
788
+ self.assertEqual(response.status_code, 302, f"Import did not redirect; status={response.status_code}")
789
+ after_count = InterfaceNameRule.objects.count()
790
+ self.assertGreater(after_count, before_count, "No new rule was created after CSV import")
791
+
792
+
793
+ # ---------------------------------------------------------------------------
794
+ # YAMLExportTest — YAML export via NetBox's built-in Export dropdown
795
+ # ---------------------------------------------------------------------------
796
+
797
+
798
+ class YAMLExportTest(ViewTestBase):
799
+ """Tests for YAML export via the list view's ?export query parameter."""
800
+
801
+ @classmethod
802
+ def setUpTestData(cls):
803
+ super().setUpTestData()
804
+ cls.LIST_URL = reverse("plugins:netbox_interface_name_rules:interfacenamerule_list")
805
+
806
+ def test_yaml_export_unauthenticated_responds(self):
807
+ """Unauthenticated export must not serve content to anonymous users."""
808
+ self.client.logout()
809
+ response = self.client.get(self.LIST_URL, {"export": ""})
810
+ self.assertIn(response.status_code, [301, 302, 403])
811
+
812
+ def test_yaml_export_all_returns_200(self):
813
+ """Authenticated GET ?export= must return 200."""
814
+ self.client.force_login(self.superuser)
815
+ response = self.client.get(self.LIST_URL, {"export": ""})
816
+ self.assertEqual(response.status_code, 200)
817
+
818
+ def test_yaml_export_content_type(self):
819
+ """Response Content-Type must contain 'yaml'."""
820
+ self.client.force_login(self.superuser)
821
+ response = self.client.get(self.LIST_URL, {"export": ""})
822
+ self.assertEqual(response.status_code, 200)
823
+ self.assertIn("yaml", response["Content-Type"].lower())
824
+
825
+ def test_yaml_export_content_disposition(self):
826
+ """Response must include a Content-Disposition attachment header."""
827
+ self.client.force_login(self.superuser)
828
+ response = self.client.get(self.LIST_URL, {"export": ""})
829
+ self.assertEqual(response.status_code, 200)
830
+ self.assertIn("attachment", response.get("Content-Disposition", "").lower())
831
+
832
+ def test_yaml_export_all_contains_rules(self):
833
+ """Exported YAML must be parseable, contain all rules, in PK order."""
834
+ import yaml
835
+
836
+ self.client.force_login(self.superuser)
837
+ response = self.client.get(self.LIST_URL, {"export": ""})
838
+ self.assertEqual(response.status_code, 200)
839
+ data = yaml.safe_load(response.content)
840
+ self.assertIsInstance(data, list)
841
+ expected_templates = list(InterfaceNameRule.objects.order_by("pk").values_list("name_template", flat=True))
842
+ exported_templates = [entry["name_template"] for entry in data]
843
+ self.assertEqual(exported_templates, expected_templates)
844
+
845
+ def test_yaml_export_structure(self):
846
+ """Each exported YAML entry must contain key fields and no blank optional keys."""
847
+ import yaml
848
+
849
+ self.client.force_login(self.superuser)
850
+ response = self.client.get(self.LIST_URL, {"export": ""})
851
+ self.assertEqual(response.status_code, 200)
852
+ rules = yaml.safe_load(response.content)
853
+ self.assertIsInstance(rules, list)
854
+ optional_headers = set(InterfaceNameRule.csv_headers) - {"name_template"}
855
+ for entry in rules:
856
+ self.assertIsInstance(entry, dict)
857
+ self.assertIn("name_template", entry)
858
+ for key, value in entry.items():
859
+ if key in optional_headers:
860
+ self.assertNotIn(value, ["", None], f"Key {key!r} has blank value in exported rule")
@@ -4,16 +4,17 @@ import dataclasses
4
4
  import logging
5
5
  import re
6
6
 
7
+ import yaml
7
8
  from django.conf import settings
8
9
  from django.contrib import messages
9
10
  from django.core.exceptions import PermissionDenied
10
11
  from django.http import JsonResponse
11
- from django.shortcuts import get_object_or_404, redirect, render
12
+ from django.shortcuts import redirect, render
12
13
  from django.urls import reverse
13
14
  from django.utils.http import url_has_allowed_host_and_scheme
14
- from django.views import View
15
15
  from netbox.views import generic
16
- from utilities.views import ConditionalLoginRequiredMixin, register_model_view
16
+ from netbox.views.generic.base import BaseMultiObjectView
17
+ from utilities.views import register_model_view
17
18
 
18
19
  from .filters import InterfaceNameRuleFilterSet
19
20
  from .forms import InterfaceNameRuleFilterForm, InterfaceNameRuleForm, InterfaceNameRuleImportForm, RuleTestForm
@@ -44,6 +45,19 @@ class RulePreview:
44
45
  channel_start: int
45
46
 
46
47
 
48
+ try:
49
+ from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
50
+
51
+ class _YAMLOnlyExport(BulkExport):
52
+ """Export action that only offers YAML (no CSV "Current View" option)."""
53
+
54
+ template_name = "netbox_interface_name_rules/buttons/export_yaml_only.html"
55
+
56
+ _LIST_VIEW_ACTIONS: tuple = (AddObject, BulkImport, _YAMLOnlyExport, BulkEdit, BulkRename, BulkDelete)
57
+ except ImportError:
58
+ _LIST_VIEW_ACTIONS = None
59
+
60
+
47
61
  class InterfaceNameRuleListView(generic.ObjectListView):
48
62
  """List view for InterfaceNameRule."""
49
63
 
@@ -52,14 +66,21 @@ class InterfaceNameRuleListView(generic.ObjectListView):
52
66
  filterset = InterfaceNameRuleFilterSet
53
67
  filterset_form = InterfaceNameRuleFilterForm
54
68
  template_name = "netbox_interface_name_rules/interfacenamerule_list.html"
69
+ if _LIST_VIEW_ACTIONS is not None:
70
+ actions = _LIST_VIEW_ACTIONS
55
71
 
56
- def get_extra_context(self, request):
57
- """Inject feature-detection flags into the list template context."""
58
- from .utils import supports_module_path
59
-
60
- return {
61
- "supports_module_path": supports_module_path(),
62
- }
72
+ def export_yaml(self):
73
+ """Export all rules as a single YAML list (overrides NetBox's per-object concatenation)."""
74
+ data = []
75
+ for rule in self.queryset.order_by("pk").select_related(
76
+ "module_type", "parent_module_type", "device_type", "platform"
77
+ ):
78
+ entry = {}
79
+ for header, value in zip(InterfaceNameRule.csv_headers, rule.to_csv()):
80
+ if (value != "" and value is not None) or header in {"name_template"}:
81
+ entry[header] = value
82
+ data.append(entry)
83
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
63
84
 
64
85
 
65
86
  class InterfaceNameRuleCreateView(generic.ObjectEditView):
@@ -109,39 +130,46 @@ class InterfaceNameRuleChangeLogView(generic.ObjectChangeLogView):
109
130
  queryset = InterfaceNameRule.objects.all()
110
131
 
111
132
 
112
- class InterfaceNameRuleDuplicateView(ConditionalLoginRequiredMixin, View):
133
+ class InterfaceNameRuleDuplicateView(generic.ObjectView):
113
134
  """Redirect to the add view pre-populated with a clone of the given rule."""
114
135
 
115
- def get(self, request, pk):
116
- """Redirect to the add view pre-populated with fields cloned from rule pk."""
136
+ queryset = InterfaceNameRule.objects.all()
137
+
138
+ def get(self, request, **kwargs):
139
+ """Redirect to the add view pre-populated with fields cloned from the given rule."""
117
140
  from utilities.querydict import prepare_cloned_fields
118
141
 
119
- rule = get_object_or_404(InterfaceNameRule, pk=pk)
142
+ rule = self.get_object(**kwargs)
120
143
  params = prepare_cloned_fields(rule)
121
144
  url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add")
122
145
  return redirect(f"{url}?{params.urlencode()}")
123
146
 
124
147
 
125
- class RuleTestView(ConditionalLoginRequiredMixin, View):
148
+ class RuleTestView(BaseMultiObjectView):
126
149
  """Live-preview a name template with user-supplied variable values and optional DB lookup."""
127
150
 
151
+ queryset = InterfaceNameRule.objects.all()
128
152
  template_name = "netbox_interface_name_rules/rule_test.html"
129
153
 
130
- def _check_permission(self, request):
131
- if not request.user.has_perm("netbox_interface_name_rules.add_interfacenamerule"):
132
- raise PermissionDenied
154
+ def get_required_permission(self):
155
+ """Return the permission required to access the rule test tool."""
156
+ return "netbox_interface_name_rules.add_interfacenamerule"
133
157
 
134
158
  def get(self, request):
135
159
  """Render the test form, pre-populated from rule_id query param if given."""
136
- self._check_permission(request)
137
160
  initial = {}
138
161
  loaded_rule = None
139
162
  rule_id = request.GET.get("rule_id")
140
- if rule_id:
163
+ can_view = request.user.has_perm("netbox_interface_name_rules.view_interfacenamerule")
164
+ if rule_id and not can_view:
165
+ messages.warning(request, "You do not have permission to load an existing rule.")
166
+ if rule_id and can_view:
141
167
  try:
142
- loaded_rule = InterfaceNameRule.objects.select_related(
143
- "module_type", "parent_module_type", "device_type", "platform"
144
- ).get(pk=int(rule_id))
168
+ loaded_rule = (
169
+ InterfaceNameRule.objects.restrict(request.user, "view")
170
+ .select_related("module_type", "parent_module_type", "device_type", "platform")
171
+ .get(pk=int(rule_id))
172
+ )
145
173
  initial = {
146
174
  "name_template": loaded_rule.name_template,
147
175
  "module_type_is_regex": loaded_rule.module_type_is_regex,
@@ -159,7 +187,6 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
159
187
 
160
188
  def post(self, request):
161
189
  """Evaluate the submitted template and return a preview or redirect to save."""
162
- self._check_permission(request)
163
190
  form = RuleTestForm(request.POST)
164
191
  preview_results = None
165
192
  db_preview = None
@@ -188,6 +215,22 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
188
215
  },
189
216
  )
190
217
 
218
+ def _find_existing_rule(self, cd, user=None):
219
+ """Return the first existing rule matching the form data, or None."""
220
+ module_type_is_regex = cd.get("module_type_is_regex", False)
221
+ qs = InterfaceNameRule.objects.restrict(user, "view") if user else InterfaceNameRule.objects.all()
222
+ if module_type_is_regex:
223
+ qs = qs.filter(module_type_is_regex=True, module_type_pattern=cd.get("module_type_pattern", ""))
224
+ else:
225
+ qs = qs.filter(module_type_is_regex=False, module_type=cd.get("module_type"))
226
+ for field in ("parent_module_type", "device_type", "platform"):
227
+ val = cd.get(field)
228
+ if val:
229
+ qs = qs.filter(**{field: val})
230
+ else:
231
+ qs = qs.filter(**{f"{field}__isnull": True})
232
+ return qs.first()
233
+
191
234
  def _handle_save_rule(self, request, cd):
192
235
  """Find an existing matching rule or redirect to the add-rule form with pre-filled params."""
193
236
  from urllib.parse import urlencode
@@ -197,26 +240,20 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
197
240
  channel_start = cd.get("channel_start") or 0
198
241
  module_type_is_regex = cd.get("module_type_is_regex", False)
199
242
  module_type = cd.get("module_type")
200
- module_type_pattern = cd.get("module_type_pattern", "")
201
243
 
202
- qs = InterfaceNameRule.objects.all()
203
- if module_type_is_regex:
204
- qs = qs.filter(module_type_is_regex=True, module_type_pattern=module_type_pattern)
205
- else:
206
- qs = qs.filter(module_type_is_regex=False, module_type=module_type)
207
- for field in ("parent_module_type", "device_type", "platform"):
208
- val = cd.get(field)
209
- if val:
210
- qs = qs.filter(**{field: val})
211
- else:
212
- qs = qs.filter(**{f"{field}__isnull": True})
213
- existing = qs.first()
214
- if existing:
215
- messages.info(
216
- request,
217
- f"A matching rule already exists (#{existing.pk}). Redirecting to edit it.",
218
- )
219
- return redirect(reverse("plugins:netbox_interface_name_rules:interfacenamerule_edit", args=[existing.pk]))
244
+ # Skip duplicate detection when the user lacks view permission — we cannot
245
+ # query existing rules without it, so add-only users always land on the
246
+ # create form (potentially allowing duplicates).
247
+ if request.user.has_perm("netbox_interface_name_rules.view_interfacenamerule"):
248
+ existing = self._find_existing_rule(cd, request.user)
249
+ if existing:
250
+ messages.info(
251
+ request,
252
+ f"A matching rule already exists (#{existing.pk}). Redirecting to edit it.",
253
+ )
254
+ return redirect(
255
+ reverse("plugins:netbox_interface_name_rules:interfacenamerule_edit", args=[existing.pk])
256
+ )
220
257
 
221
258
  params = {
222
259
  "name_template": name_template,
@@ -225,7 +262,7 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
225
262
  "channel_start": channel_start,
226
263
  }
227
264
  if module_type_is_regex:
228
- params["module_type_pattern"] = module_type_pattern
265
+ params["module_type_pattern"] = cd.get("module_type_pattern", "")
229
266
  elif module_type:
230
267
  params["module_type"] = module_type.pk
231
268
  for field in ("parent_module_type", "device_type", "platform"):
@@ -311,37 +348,42 @@ class RuleTestView(ConditionalLoginRequiredMixin, View):
311
348
  return [], 0, f"Unexpected error: {type(exc).__name__}"
312
349
 
313
350
 
314
- class RuleApplyListView(ConditionalLoginRequiredMixin, View):
351
+ class RuleApplyListView(BaseMultiObjectView):
315
352
  """Display all rules with buttons to preview/apply each one."""
316
353
 
354
+ queryset = InterfaceNameRule.objects.all()
317
355
  template_name = "netbox_interface_name_rules/rule_apply.html"
318
356
 
357
+ def get_required_permission(self):
358
+ """Return the permission required to access the apply-rules page."""
359
+ return "netbox_interface_name_rules.view_interfacenamerule"
360
+
319
361
  def get(self, request):
320
362
  """Render the list of all rules with apply/preview buttons."""
321
- rules = InterfaceNameRule.objects.select_related(
322
- "module_type", "parent_module_type", "device_type", "platform"
323
- ).order_by("pk")
363
+ rules = self.queryset.select_related("module_type", "parent_module_type", "device_type", "platform").order_by(
364
+ "pk"
365
+ )
324
366
  return render(request, self.template_name, {"rules": rules, "batch_limit": APPLY_BATCH_LIMIT})
325
367
 
326
368
 
327
- class RuleApplicableView(ConditionalLoginRequiredMixin, View):
369
+ class RuleApplicableView(generic.ObjectView):
328
370
  """Return JSON indicating whether a rule would rename at least one interface.
329
371
 
330
372
  Called on demand from the Apply Rules page — NOT at page load — to avoid
331
373
  expensive full-scan queries blocking the initial render.
332
374
  """
333
375
 
334
- def get(self, request, pk):
376
+ queryset = InterfaceNameRule.objects.all()
377
+
378
+ def get(self, request, **kwargs):
335
379
  """Return JSON {"applicable": bool} for the rule identified by pk."""
336
380
  from .engine import has_applicable_interfaces
337
381
 
338
- rule = get_object_or_404(InterfaceNameRule, pk=pk)
382
+ rule = self.get_object(**kwargs)
339
383
  try:
340
384
  applicable = has_applicable_interfaces(rule)
341
385
  except Exception as exc:
342
- logger.exception("applicability scan failed for rule %s", pk)
343
- # Only expose the exception class name to avoid leaking internals
344
- # (SQL, file paths, etc.). Full details are in the server log.
386
+ logger.exception("applicability scan failed for rule %s", kwargs.get("pk"))
345
387
  return JsonResponse(
346
388
  {"applicable": None, "error": f"scan failed: {type(exc).__name__}"},
347
389
  status=500,
@@ -349,21 +391,18 @@ class RuleApplicableView(ConditionalLoginRequiredMixin, View):
349
391
  return JsonResponse({"applicable": applicable})
350
392
 
351
393
 
352
- class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
394
+ class RuleApplyDetailView(generic.ObjectView):
353
395
  """Show a preview of changes for a specific rule and allow applying them."""
354
396
 
397
+ queryset = InterfaceNameRule.objects.all()
355
398
  template_name = "netbox_interface_name_rules/rule_apply_detail.html"
399
+ additional_permissions = ["dcim.change_interface"]
356
400
 
357
- def _check_permission(self, request):
358
- if not request.user.has_perm("dcim.change_interface"):
359
- raise PermissionDenied
360
-
361
- def get(self, request, pk):
401
+ def get(self, request, **kwargs):
362
402
  """Render a preview of all interfaces that would be renamed by this rule."""
363
403
  from .engine import find_interfaces_for_rule
364
404
 
365
- self._check_permission(request)
366
- rule = get_object_or_404(InterfaceNameRule, pk=pk)
405
+ rule = self.get_object(**kwargs)
367
406
  try:
368
407
  preview, total_checked = find_interfaces_for_rule(rule, limit=APPLY_BATCH_LIMIT)
369
408
  except (re.error, ValueError) as exc:
@@ -387,13 +426,11 @@ class RuleApplyDetailView(ConditionalLoginRequiredMixin, View):
387
426
  },
388
427
  )
389
428
 
390
- def post(self, request, pk):
429
+ def post(self, request, **kwargs):
391
430
  """Apply the rule (foreground batch or background job) and redirect back."""
392
431
  from .engine import apply_rule_to_existing
393
432
 
394
- self._check_permission(request)
395
-
396
- rule = get_object_or_404(InterfaceNameRule, pk=pk)
433
+ rule = self.get_object(**kwargs)
397
434
  action = request.POST.get("action", "apply")
398
435
 
399
436
  if action == "background":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.2.3
3
+ Version: 1.3.1
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
@@ -11,7 +11,6 @@ netbox_interface_name_rules/navigation.py
11
11
  netbox_interface_name_rules/signals.py
12
12
  netbox_interface_name_rules/tables.py
13
13
  netbox_interface_name_rules/urls.py
14
- netbox_interface_name_rules/utils.py
15
14
  netbox_interface_name_rules/views.py
16
15
  netbox_interface_name_rules.egg-info/PKG-INFO
17
16
  netbox_interface_name_rules.egg-info/SOURCES.txt
@@ -39,6 +38,7 @@ netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamer
39
38
  netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html
40
39
  netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html
41
40
  netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html
41
+ netbox_interface_name_rules/templates/netbox_interface_name_rules/buttons/export_yaml_only.html
42
42
  netbox_interface_name_rules/tests/__init__.py
43
43
  netbox_interface_name_rules/tests/test_api.py
44
44
  netbox_interface_name_rules/tests/test_device_rules.py
@@ -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.3"
9
+ version = "1.3.1"
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"
@@ -1,17 +0,0 @@
1
- # SPDX-License-Identifier: Apache-2.0
2
- # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
- """Feature-detection utilities for the interface name rules plugin."""
4
-
5
-
6
- def supports_module_path():
7
- """Check if the running NetBox supports the {module_path} template token.
8
-
9
- Detects by checking for MODULE_PATH_TOKEN in dcim.constants rather than
10
- comparing version strings — works with patched/pre-release builds too.
11
- """
12
- try:
13
- from dcim.constants import MODULE_PATH_TOKEN # noqa: F401
14
-
15
- return True
16
- except ImportError:
17
- return False