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.
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/PKG-INFO +1 -1
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/__init__.py +1 -1
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/engine.py +1 -2
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0003_constraints.py +1 -1
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/models.py +43 -1
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tables.py +1 -1
- netbox_interface_name_rules-1.3.1/netbox_interface_name_rules/templates/netbox_interface_name_rules/buttons/export_yaml_only.html +33 -0
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_misc.py +71 -40
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_views.py +198 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/views.py +103 -66
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/PKG-INFO +1 -1
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules.egg-info/SOURCES.txt +1 -1
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/pyproject.toml +1 -1
- netbox_interface_name_rules-1.2.3/netbox_interface_name_rules/utils.py +0 -17
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/LICENSE +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/README.md +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/serializers.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/urls.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/api/views.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/filters.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/forms.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/jobs.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
- {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
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/migrations/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/navigation.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/signals.py +0 -0
- {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
- {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
- {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
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/__init__.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_api.py +0 -0
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_engine.py +0 -0
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_regex.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_rules.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/tests/test_signals.py +0 -0
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/netbox_interface_name_rules/urls.py +0 -0
- {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
- {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
- {netbox_interface_name_rules-1.2.3 → netbox_interface_name_rules-1.3.1}/setup.cfg +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
-
#
|
|
730
|
+
# Model csv_headers / to_csv() — regression: KeyError 'Ch' on CSV import
|
|
746
731
|
# ---------------------------------------------------------------------------
|
|
747
732
|
|
|
748
733
|
|
|
749
|
-
class
|
|
750
|
-
"""Test
|
|
734
|
+
class ModelCSVExportTest(TestCase):
|
|
735
|
+
"""Test that InterfaceNameRule exposes csv_headers and to_csv() for round-trip CSV."""
|
|
751
736
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
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
|
|
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
|
|
57
|
-
"""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
133
|
+
class InterfaceNameRuleDuplicateView(generic.ObjectView):
|
|
113
134
|
"""Redirect to the add view pre-populated with a clone of the given rule."""
|
|
114
135
|
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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 =
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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(
|
|
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 =
|
|
322
|
-
"
|
|
323
|
-
)
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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.
|
|
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,
|
|
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.
|
|
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":
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|