Skip to content

Commit 3ca6d09

Browse files
committed
refactor: wire YAML export into NetBox's built-in Export dropdown
Replace the standalone InterfaceNameRuleYAMLExportView with NetBox's native export mechanism: - Add to_yaml() to InterfaceNameRule model so NetBox offers YAML in the Export dropdown - Override export_yaml() on the list view for deterministic pk-ordered output with empty-field stripping - Remove standalone /rules/export/yaml/ URL and custom template buttons - Rewrite tests to use the list view's ?export= parameter
1 parent 3fbc321 commit 3ca6d09

5 files changed

Lines changed: 36 additions & 99 deletions

File tree

netbox_interface_name_rules/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,13 @@ def to_csv(self):
315315
self.enabled,
316316
self.applies_to_device_interfaces,
317317
)
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)

netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -145,21 +145,3 @@ <h6 class="mt-3">Examples</h6>
145145
</script>
146146
{{ block.super }}
147147
{% endblock %}
148-
149-
{% block extra_controls %}
150-
<a href="{% url 'plugins:netbox_interface_name_rules:interfacenamerule_export_yaml' %}"
151-
class="btn btn-outline-secondary btn-sm"
152-
title="Export all rules as YAML">
153-
<i class="mdi mdi-download"></i> Export YAML
154-
</a>
155-
{% endblock %}
156-
157-
{% block bulk_buttons %}
158-
{{ block.super }}
159-
<button type="submit"
160-
formaction="{% url 'plugins:netbox_interface_name_rules:interfacenamerule_export_yaml' %}"
161-
class="btn btn-outline-secondary btn-sm"
162-
title="Export selected rules as YAML">
163-
<i class="mdi mdi-download"></i> Export Selected YAML
164-
</button>
165-
{% endblock %}

netbox_interface_name_rules/tests/test_views.py

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -790,47 +790,41 @@ def test_csv_round_trip_creates_rule(self):
790790

791791

792792
# ---------------------------------------------------------------------------
793-
# YAMLExportViewTestnew YAML export view
793+
# YAMLExportTest — YAML export via NetBox's built-in Export dropdown
794794
# ---------------------------------------------------------------------------
795795

796796

797-
class YAMLExportViewTest(ViewTestBase):
798-
"""Tests for the YAML export view at GET/POST /rules/export/yaml/."""
797+
class YAMLExportTest(ViewTestBase):
798+
"""Tests for YAML export via the list view's ?export query parameter."""
799799

800800
@classmethod
801801
def setUpTestData(cls):
802802
super().setUpTestData()
803-
cls.YAML_URL = reverse("plugins:netbox_interface_name_rules:interfacenamerule_export_yaml")
803+
cls.LIST_URL = reverse("plugins:netbox_interface_name_rules:interfacenamerule_list")
804804

805805
def test_yaml_export_unauthenticated_responds(self):
806-
"""Unauthenticated GET must not serve content to anonymous users.
807-
808-
setUp() logs in the superuser, so we explicitly logout first to exercise
809-
anonymous access. When LOGIN_REQUIRED=True the mixin redirects (302);
810-
when False the view's explicit has_perm check raises PermissionDenied
811-
(403). 200 is never valid because AnonymousUser lacks the permission.
812-
"""
806+
"""Unauthenticated export must not serve content to anonymous users."""
813807
self.client.logout()
814-
response = self.client.get(self.YAML_URL)
808+
response = self.client.get(self.LIST_URL, {"export": ""})
815809
self.assertIn(response.status_code, [301, 302, 403])
816810

817811
def test_yaml_export_all_returns_200(self):
818-
"""Authenticated GET /rules/export/yaml/ must return 200."""
812+
"""Authenticated GET ?export= must return 200."""
819813
self.client.force_login(self.superuser)
820-
response = self.client.get(self.YAML_URL)
814+
response = self.client.get(self.LIST_URL, {"export": ""})
821815
self.assertEqual(response.status_code, 200)
822816

823817
def test_yaml_export_content_type(self):
824818
"""Response Content-Type must contain 'yaml'."""
825819
self.client.force_login(self.superuser)
826-
response = self.client.get(self.YAML_URL)
820+
response = self.client.get(self.LIST_URL, {"export": ""})
827821
self.assertEqual(response.status_code, 200)
828822
self.assertIn("yaml", response["Content-Type"].lower())
829823

830824
def test_yaml_export_content_disposition(self):
831825
"""Response must include a Content-Disposition attachment header."""
832826
self.client.force_login(self.superuser)
833-
response = self.client.get(self.YAML_URL)
827+
response = self.client.get(self.LIST_URL, {"export": ""})
834828
self.assertEqual(response.status_code, 200)
835829
self.assertIn("attachment", response.get("Content-Disposition", "").lower())
836830

@@ -839,33 +833,18 @@ def test_yaml_export_all_contains_rules(self):
839833
import yaml
840834

841835
self.client.force_login(self.superuser)
842-
response = self.client.get(self.YAML_URL)
836+
response = self.client.get(self.LIST_URL, {"export": ""})
843837
self.assertEqual(response.status_code, 200)
844838
data = yaml.safe_load(response.content)
845839
self.assertIsInstance(data, list)
846840
self.assertEqual(len(data), InterfaceNameRule.objects.count())
847841

848-
def test_yaml_export_selected_rules_via_post(self):
849-
"""POST with NetBox bulk-select pk inputs must export only the selected rules."""
850-
import yaml
851-
852-
self.client.force_login(self.superuser)
853-
response = self.client.post(
854-
self.YAML_URL,
855-
{"pk": [self.rule.pk]},
856-
)
857-
self.assertEqual(response.status_code, 200)
858-
data = yaml.safe_load(response.content)
859-
self.assertIsInstance(data, list)
860-
self.assertEqual(len(data), 1)
861-
self.assertEqual(data[0]["name_template"], self.rule.name_template)
862-
863842
def test_yaml_export_structure(self):
864843
"""Each exported YAML entry must contain key fields."""
865844
import yaml
866845

867846
self.client.force_login(self.superuser)
868-
response = self.client.get(self.YAML_URL)
847+
response = self.client.get(self.LIST_URL, {"export": ""})
869848
self.assertEqual(response.status_code, 200)
870849
rules = yaml.safe_load(response.content)
871850
self.assertIsInstance(rules, list)

netbox_interface_name_rules/urls.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
path("rules/add/", views.InterfaceNameRuleCreateView.as_view(), name="interfacenamerule_add"),
1313
path("rules/import/", views.InterfaceNameRuleBulkImportView.as_view(), name="interfacenamerule_bulk_import"),
1414
path("rules/bulk_delete/", views.InterfaceNameRuleBulkDeleteView.as_view(), name="interfacenamerule_bulk_delete"),
15-
path("rules/export/yaml/", views.InterfaceNameRuleYAMLExportView.as_view(), name="interfacenamerule_export_yaml"),
1615
# Rule tester (Build Rule)
1716
path("rules/test/", views.RuleTestView.as_view(), name="interfacenamerule_test"),
1817
# Apply rules to existing interfaces

netbox_interface_name_rules/views.py

Lines changed: 14 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.conf import settings
99
from django.contrib import messages
1010
from django.core.exceptions import PermissionDenied
11-
from django.http import HttpResponse, JsonResponse
11+
from django.http import JsonResponse
1212
from django.shortcuts import redirect, render
1313
from django.urls import reverse
1414
from django.utils.http import url_has_allowed_host_and_scheme
@@ -54,6 +54,19 @@ class InterfaceNameRuleListView(generic.ObjectListView):
5454
filterset_form = InterfaceNameRuleFilterForm
5555
template_name = "netbox_interface_name_rules/interfacenamerule_list.html"
5656

57+
def export_yaml(self):
58+
"""Export all rules as a single YAML list (overrides NetBox's per-object concatenation)."""
59+
data = []
60+
for rule in self.queryset.order_by("pk").select_related(
61+
"module_type", "parent_module_type", "device_type", "platform"
62+
):
63+
entry = {}
64+
for header, value in zip(InterfaceNameRule.csv_headers, rule.to_csv()):
65+
if (value != "" and value is not None) or header in {"name_template"}:
66+
entry[header] = value
67+
data.append(entry)
68+
return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
69+
5770

5871
class InterfaceNameRuleCreateView(generic.ObjectEditView):
5972
"""Create view for InterfaceNameRule."""
@@ -117,52 +130,6 @@ def get(self, request, **kwargs):
117130
return redirect(f"{url}?{params.urlencode()}")
118131

119132

120-
class InterfaceNameRuleYAMLExportView(BaseMultiObjectView):
121-
"""Export selected or all rules as a YAML file importable via BulkImportView."""
122-
123-
queryset = InterfaceNameRule.objects.all()
124-
125-
def get_required_permission(self):
126-
"""Return the permission required to export rules."""
127-
return "netbox_interface_name_rules.view_interfacenamerule"
128-
129-
def _rules_to_yaml_data(self, queryset):
130-
"""Return a list of dicts (one per rule) using import-form field names."""
131-
records = []
132-
for rule in queryset.select_related("module_type", "parent_module_type", "device_type", "platform"):
133-
entry = {}
134-
for header, value in zip(InterfaceNameRule.csv_headers, rule.to_csv()):
135-
if (value != "" and value is not None) or header in {"name_template"}:
136-
entry[header] = value
137-
records.append(entry)
138-
return records
139-
140-
def _build_response(self, queryset):
141-
data = self._rules_to_yaml_data(queryset)
142-
content = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
143-
response = HttpResponse(content, content_type="application/yaml; charset=utf-8")
144-
response["Content-Disposition"] = 'attachment; filename="interface_name_rules.yaml"'
145-
return response
146-
147-
def get(self, request):
148-
"""Export all rules as YAML."""
149-
return self._build_response(self.queryset.order_by("pk"))
150-
151-
def post(self, request):
152-
"""Export selected rules as YAML using NetBox's bulk-select form (pk inputs).
153-
154-
The parent object_list.html wraps the table in a POST form; selected rows
155-
post their PKs as repeated ``pk`` inputs. Falls back to all rules when
156-
nothing is selected.
157-
"""
158-
pk_list = [int(v) for v in request.POST.getlist("pk") if v.isdigit()]
159-
if pk_list:
160-
queryset = self.queryset.filter(pk__in=pk_list).order_by("pk")
161-
else:
162-
queryset = self.queryset.order_by("pk")
163-
return self._build_response(queryset)
164-
165-
166133
class RuleTestView(BaseMultiObjectView):
167134
"""Live-preview a name template with user-supplied variable values and optional DB lookup."""
168135

0 commit comments

Comments
 (0)