Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
099f2b1
docs: add design spec for remove-module-path-plus-fixes
marcinpsk Mar 30, 2026
60d103d
feat: remove {module_path} feature detection support
marcinpsk Mar 30, 2026
f049a62
test: add failing TDD tests for CSV export round-trip and YAML export
marcinpsk Mar 30, 2026
fb1b7a1
fix: add csv_headers/to_csv() to model, fix channel_start verbose_name
marcinpsk Mar 30, 2026
7445ef9
feat: add YAML export view for all and selected rules
marcinpsk Mar 30, 2026
e9b823c
test: fix yaml_export_requires_login assertion for ConditionalLoginRe…
marcinpsk Mar 30, 2026
b734f67
refactor: address review feedback on YAML export and test naming
marcinpsk Mar 30, 2026
cc68f42
fix: wire YAML export POST to NetBox bulk-select form convention
marcinpsk Mar 30, 2026
d818504
fix: use reverse() for YAML export URL, add permission check, fix una…
marcinpsk Mar 30, 2026
fdd958a
fix: strengthen CSV round-trip and YAML export test assertions
marcinpsk Mar 31, 2026
b883698
refactor: align utility views to NetBox base classes
marcinpsk Mar 31, 2026
79022ca
fix: deterministic YAML export ordering and view permission guards
marcinpsk Mar 31, 2026
65f0f5e
fix: clarify operator precedence, add warning for add-only prefill, a…
marcinpsk Mar 31, 2026
6ad692e
fix: re-fetch user after adding permission to clear Django cache
marcinpsk Mar 31, 2026
25534d9
refactor: extract _find_existing_rule to reduce cognitive complexity
marcinpsk Mar 31, 2026
304ce97
refactor: extract PERM_VIEW_RULE constant for duplicated permission s…
marcinpsk Mar 31, 2026
3fbc321
Revert "refactor: extract PERM_VIEW_RULE constant for duplicated perm…
marcinpsk Mar 31, 2026
3ca6d09
refactor: wire YAML export into NetBox's built-in Export dropdown
marcinpsk Mar 31, 2026
2035bda
fix: use NetBox ObjectPermission for add-only user tests
marcinpsk Mar 31, 2026
9cabb29
fix: remove CSV "Current View" from Export dropdown
marcinpsk Mar 31, 2026
39cf3a6
fix: guard netbox.object_actions import for 4.3.x compatibility
marcinpsk Mar 31, 2026
6c39b35
fix: use related_model detection for ObjectPermission.object_types
marcinpsk Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ site/

# direnv
.envrc

# Brainstorming / design specs (local only, not for version control)
docs/superpowers/
18 changes: 1 addition & 17 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,14 @@ NetBox installs: interface name = "5" (raw bay position)
Plugin renames: interface name = "et-0/0/5"
```

### `{module_path}` (NetBox ≥ 4.9)

When a module type's interface template uses `{module_path}`, NetBox resolves
the full module bay path at install time. For a transceiver in "X2 Port 1"
inside a line card in "Slot 1", `{module_path}` resolves to `1/1`. The plugin
signal still fires, but may compute the same name the interface already has —
so it becomes a no-op.

For directly-attached pluggables (single bay depth), `{module_path}` resolves to
just the bay position, behaving identically to `{module}`.

!!! tip
New module types should use `{module_path}` for best NetBox compatibility.
Legacy module types using `{module}` continue to work — the plugin handles the rename.

### The `potentially-deprecated` Tag

After installing a module, if the plugin's signal fires but finds the interface
is already correctly named, it automatically tags the rule `potentially-deprecated`.
This means:

- For **new installs**: the rule may no longer be needed (NetBox generates the name)
- For **retroactive applies**: the rule is still useful for modules installed before
`{module_path}` support was added, or before the rule existed
- For **retroactive applies**: the rule is still useful for modules installed before the rule existed

The tag is informational only — the rule remains active.

Expand Down
3 changes: 1 addition & 2 deletions netbox_interface_name_rules/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,7 @@ def has_applicable_interfaces(rule) -> bool:
Calls find_interfaces_for_rule(limit=1) to determine if any currently installed
interface would receive a new name. Returns False when:
- no matching modules/interfaces are installed, OR
- all matching interfaces are already correctly named (e.g. NetBox resolved
{module_path} at install time, making the rule a no-op for existing interfaces).
- all matching interfaces are already correctly named.

This is more expensive than a plain EXISTS query but ensures the Applicable
column in the Apply Rules list accurately reflects "would something change?"
Expand Down
42 changes: 42 additions & 0 deletions netbox_interface_name_rules/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,45 @@ def __str__(self):
device = f" on {self.device_type.model}" if self.device_type else ""
platform = f" [{self.platform.name}]" if self.platform else ""
return f"{module}{parent}{device}{platform} → {self.name_template}"

csv_headers = [
"module_type",
"module_type_pattern",
"module_type_is_regex",
"parent_module_type",
"device_type",
"platform",
"name_template",
"channel_count",
"channel_start",
"description",
"enabled",
"applies_to_device_interfaces",
]

def to_csv(self):
"""Return a tuple of field values for CSV export (matches csv_headers order)."""
return (
self.module_type.model if self.module_type else "",
self.module_type_pattern,
self.module_type_is_regex,
self.parent_module_type.model if self.parent_module_type else "",
self.device_type.model if self.device_type else "",
self.platform.name if self.platform else "",
self.name_template,
self.channel_count,
self.channel_start,
self.description,
self.enabled,
self.applies_to_device_interfaces,
)

def to_yaml(self):
"""Return a YAML document for this rule (used by NetBox's built-in Export)."""
import yaml

entry = {}
for header, value in zip(self.csv_headers, self.to_csv()):
if (value != "" and value is not None) or header in {"name_template"}:
entry[header] = value
return yaml.dump([entry], default_flow_style=False, allow_unicode=True, sort_keys=False)
2 changes: 1 addition & 1 deletion netbox_interface_name_rules/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class InterfaceNameRuleTable(NetBoxTable):
applies_to_device_interfaces = columns.BooleanColumn(verbose_name="Device Ifaces")
name_template = tables.Column(verbose_name="Name Template")
channel_count = tables.Column(verbose_name="Channels")
channel_start = tables.Column(verbose_name="Ch. Start")
channel_start = tables.Column(verbose_name="Channel Start")
description = tables.Column(verbose_name="Description", linkify=False)
actions = columns.ActionsColumn(
actions=("edit", "delete"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{# SPDX-License-Identifier: Apache-2.0 #}
{# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> #}
{% load i18n %}
<div class="dropdown">
<button type="button" class="btn btn-purple dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-download" aria-hidden="true"></i> {{ label }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
{% if export_templates %}
<li>
<hr class="dropdown-divider">
</li>
{% for et in export_templates %}
<li>
<a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export={{ et.name|urlencode }}"
{% if et.description %} title="{{ et.description }}"{% endif %}
>
{{ et.name }}
</a>
</li>
{% endfor %}
{% endif %}
{% if perms.extras.add_exporttemplate %}
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?object_types={{ object_type.pk }}">{% trans "Add export template" %}...</a>
</li>
{% endif %}
</ul>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,6 @@ <h6 class="mt-3">Examples</h6>
(device-level rule, pattern <code>Ethernet\d+/\d+</code>)</li>
</ul>

{% if supports_module_path %}
<div class="alert alert-success mt-3 mb-0">
<i class="mdi mdi-check-circle me-1"></i>
<strong><code>{module_path}</code> supported:</strong>
Platform naming rules (e.g., <code>et-0/0/{bay_position}</code>)
may be replaceable by using <code>{module_path}</code> in module type interface templates directly.
Converter offset and breakout rules are still needed.
</div>
{% else %}
<div class="alert alert-warning mt-3 mb-0">
<i class="mdi mdi-information-outline me-1"></i>
<strong><code>{module_path}</code> not available:</strong>
Platform naming rules
(e.g., Juniper <code>et-0/0/{bay_position}</code>) are required for correct interface naming.
Upgrade NetBox to get native <code>{module_path}</code> support.
</div>
{% endif %}
</div>
</div>
<script>
Expand Down
111 changes: 71 additions & 40 deletions netbox_interface_name_rules/tests/test_misc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
"""Tests for utils, jobs, model properties, and API serializer edge-cases."""
"""Tests for jobs, model properties, and API serializer edge-cases."""

from unittest.mock import MagicMock, patch

Expand All @@ -9,21 +9,6 @@
from django.test import TestCase

from netbox_interface_name_rules.models import InterfaceNameRule
from netbox_interface_name_rules.utils import supports_module_path

# ---------------------------------------------------------------------------
# utils.py
# ---------------------------------------------------------------------------


class SupportModulePathTest(TestCase):
"""Test the supports_module_path feature-detection helper."""

def test_returns_bool(self):
"""supports_module_path() returns a boolean (True or False)."""
result = supports_module_path()
self.assertIsInstance(result, bool)


# ---------------------------------------------------------------------------
# jobs.py
Expand Down Expand Up @@ -742,31 +727,77 @@ def test_job_run_exception_reraises_and_logs(self):


# ---------------------------------------------------------------------------
# utils.py — supports_module_path ImportError path (lines 16-17)
# Model csv_headers / to_csv() — regression: KeyError 'Ch' on CSV import
# ---------------------------------------------------------------------------


class UtilsModulePathFalseTest(TestCase):
"""Test supports_module_path() returns False when MODULE_PATH_TOKEN is missing."""
class ModelCSVExportTest(TestCase):
"""Test that InterfaceNameRule exposes csv_headers and to_csv() for round-trip CSV."""

def test_returns_false_when_token_missing(self):
"""supports_module_path() returns False when MODULE_PATH_TOKEN is absent.

Temporarily removes the attribute from dcim.constants (if present) to
trigger the ImportError branch in supports_module_path, then restores it.
Asserts False regardless of whether the attribute existed beforehand.
"""
import dcim.constants as dc

from netbox_interface_name_rules.utils import supports_module_path

had_attr = hasattr(dc, "MODULE_PATH_TOKEN")
original = getattr(dc, "MODULE_PATH_TOKEN", None)
try:
if had_attr:
delattr(dc, "MODULE_PATH_TOKEN")
result = supports_module_path()
self.assertFalse(result)
finally:
if had_attr:
dc.MODULE_PATH_TOKEN = original
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name="CSVMfg", slug="csvmfg")
cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model="CSV-SFP", part_number="CSV-SFP")
cls.rule = InterfaceNameRule.objects.create(
module_type=cls.module_type,
name_template="et-0/0/{bay_position}",
channel_count=4,
channel_start=0,
description="CSV test rule",
)
cls.regex_rule = InterfaceNameRule.objects.create(
module_type_is_regex=True,
module_type_pattern="QSFP-.*",
name_template="{base}/{channel}",
channel_count=4,
channel_start=1,
description="Regex CSV rule",
)

def test_csv_headers_attribute_exists(self):
"""InterfaceNameRule.csv_headers class attribute must exist."""
self.assertTrue(hasattr(InterfaceNameRule, "csv_headers"), "InterfaceNameRule.csv_headers missing")

def test_csv_headers_is_list_or_tuple(self):
"""csv_headers must be a list or tuple of strings."""
self.assertIsInstance(InterfaceNameRule.csv_headers, (list, tuple))

def test_csv_headers_matches_import_form_fields(self):
"""csv_headers must exactly match InterfaceNameRuleImportForm.Meta.fields."""
from netbox_interface_name_rules.forms import InterfaceNameRuleImportForm

form_fields = list(InterfaceNameRuleImportForm.Meta.fields)
self.assertEqual(list(InterfaceNameRule.csv_headers), form_fields)

def test_to_csv_method_exists(self):
"""InterfaceNameRule instances must have a to_csv() method."""
self.assertTrue(callable(getattr(self.rule, "to_csv", None)), "InterfaceNameRule.to_csv() missing")

def test_to_csv_returns_sequence(self):
"""to_csv() must return a tuple or list."""
result = self.rule.to_csv()
self.assertIsInstance(result, (tuple, list))

def test_to_csv_length_matches_csv_headers(self):
"""to_csv() must return exactly len(csv_headers) values."""
result = self.rule.to_csv()
self.assertEqual(len(result), len(InterfaceNameRule.csv_headers))

def test_to_csv_exact_rule_module_type(self):
"""to_csv() for exact rule must include the module_type model string."""
result = self.rule.to_csv()
values = list(result)
idx = list(InterfaceNameRule.csv_headers).index("module_type")
self.assertEqual(values[idx], "CSV-SFP")

def test_to_csv_regex_rule_no_module_type(self):
"""to_csv() for regex rule must have empty string for module_type."""
result = self.regex_rule.to_csv()
values = list(result)
idx = list(InterfaceNameRule.csv_headers).index("module_type")
self.assertEqual(values[idx], "")

def test_to_csv_no_dots_in_headers(self):
"""csv_headers must not contain dots (regression guard for KeyError 'Ch' bug)."""
for header in InterfaceNameRule.csv_headers:
self.assertNotIn(".", header, f"csv_headers entry '{header}' contains a dot — would break import")
Loading
Loading