Coverage for netbox_interface_name_rules / forms.py: 98.80%
137 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3from dcim.models import Device, DeviceType, Interface, ModuleBay, ModuleType, Platform
4from django import forms
5from django.core.exceptions import ValidationError
6from django.core.validators import MaxValueValidator, MinValueValidator
7from netbox.forms import (
8 NetBoxModelBulkEditForm,
9 NetBoxModelFilterSetForm,
10 NetBoxModelForm,
11 NetBoxModelImportForm,
12)
13from utilities.forms import add_blank_choice
14from utilities.forms.fields import CSVModelChoiceField, DynamicModelChoiceField
15from utilities.forms.rendering import FieldSet
16from utilities.forms.widgets import BulkEditNullBooleanSelect
18from .choices import BreakoutModeChoices
19from .models import DEVICE_RULE_PARENT_MODULE_TYPE_ERROR, InterfaceNameRule
20from .name_template import validate_rule
22# A preview variable holds a real position or interface name, so the model fields bound them.
23_POSITION_MAX_LENGTH = ModuleBay._meta.get_field("position").max_length
24_VC_POSITION_VALIDATORS = Device._meta.get_field("vc_position").validators
25_VC_POSITION_MIN = max(v.limit_value for v in _VC_POSITION_VALIDATORS if isinstance(v, MinValueValidator))
26_VC_POSITION_MAX = min(v.limit_value for v in _VC_POSITION_VALIDATORS if isinstance(v, MaxValueValidator))
27_INTERFACE_NAME_MAX_LENGTH = Interface._meta.get_field("name").max_length
30class RuleTestForm(forms.Form):
31 """Standalone form for previewing interface name rule output without saving."""
33 device_pattern_label = "Interface-name filter (RE2)"
34 device_pattern_help_text = "Optional RE2 pattern matched against the complete interface name"
36 applies_to_device_interfaces = forms.BooleanField(
37 required=False,
38 label="Device-level interfaces",
39 widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
40 )
41 var_vc_position = forms.IntegerField(
42 required=False,
43 initial=1,
44 min_value=_VC_POSITION_MIN,
45 max_value=_VC_POSITION_MAX,
46 label="{vc_position}",
47 help_text=(
48 "Module rules: leave blank for a device outside a virtual chassis. Device rules require a position."
49 ),
50 widget=forms.NumberInput(attrs={"class": "form-control"}),
51 )
53 # --- Rule definition ---
54 module_type_is_regex = forms.BooleanField(
55 required=False,
56 label="Use Regex Pattern",
57 initial=False,
58 widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
59 )
60 module_type = forms.ModelChoiceField(
61 queryset=ModuleType.objects.all(),
62 required=False,
63 label="Module Type (exact)",
64 help_text="FK match — used when regex mode is off",
65 widget=forms.Select(attrs={"class": "form-select"}),
66 )
67 module_type_pattern = forms.CharField(
68 required=False,
69 label="Module Type Pattern (RE2)",
70 help_text="RE2 pattern matched against the complete ModuleType.model value",
71 widget=forms.TextInput(attrs={"class": "form-control"}),
72 )
73 parent_module_type = forms.ModelChoiceField(
74 queryset=ModuleType.objects.all(),
75 required=False,
76 label="Parent Module Type",
77 help_text="Optional: scope to modules installed inside this parent module type",
78 widget=forms.Select(attrs={"class": "form-select"}),
79 )
80 device_type = forms.ModelChoiceField(
81 queryset=DeviceType.objects.all(),
82 required=False,
83 label="Device Type",
84 help_text="Optional: scope to devices of this hardware model",
85 widget=forms.Select(attrs={"class": "form-select"}),
86 )
87 platform = forms.ModelChoiceField(
88 queryset=Platform.objects.all(),
89 required=False,
90 label="Platform",
91 help_text="Optional: scope to devices running this software platform/OS (e.g. SONiC, EOS)",
92 widget=forms.Select(attrs={"class": "form-select"}),
93 )
94 name_template = forms.CharField(
95 required=True,
96 label="Name Template",
97 help_text="e.g. et-0/0/{bay_position} or {base}:{channel}",
98 widget=forms.TextInput(attrs={"class": "form-control"}),
99 )
100 parent_name_template = forms.CharField(
101 required=False,
102 label="Parent Name Template",
103 help_text="Channelized mode only: name for the parent interface, e.g. et-0/0/{bay_position}",
104 widget=forms.TextInput(attrs={"class": "form-control"}),
105 )
106 breakout_mode = forms.ChoiceField(
107 required=False,
108 choices=BreakoutModeChoices,
109 initial=BreakoutModeChoices.FLAT,
110 label="Breakout Mode",
111 help_text="flat = sibling interfaces; channelized = one parent with channel subinterfaces",
112 widget=forms.Select(attrs={"class": "form-select"}),
113 )
114 channel_count = forms.IntegerField(
115 required=False,
116 initial=0,
117 min_value=0,
118 label="Channel Count",
119 help_text="0 = no breakout; > 0 generates one interface per channel",
120 widget=forms.NumberInput(attrs={"class": "form-control"}),
121 )
122 channel_start = forms.IntegerField(
123 required=False,
124 initial=0,
125 min_value=0,
126 label="Channel Start",
127 help_text="Starting channel index (0 for Juniper, varies for Cisco)",
128 widget=forms.NumberInput(attrs={"class": "form-control"}),
129 )
131 # --- Variable override fields ---
132 var_slot = forms.CharField(
133 required=False,
134 initial="1",
135 max_length=_POSITION_MAX_LENGTH,
136 label="{slot}",
137 widget=forms.TextInput(attrs={"class": "form-control"}),
138 )
139 var_bay_position = forms.CharField(
140 required=False,
141 initial="1",
142 max_length=_POSITION_MAX_LENGTH,
143 label="{bay_position}",
144 widget=forms.TextInput(attrs={"class": "form-control"}),
145 )
146 var_parent_bay_position = forms.CharField(
147 required=False,
148 initial="1",
149 max_length=_POSITION_MAX_LENGTH,
150 label="{parent_bay_position}",
151 widget=forms.TextInput(attrs={"class": "form-control"}),
152 )
153 var_base = forms.CharField(
154 required=False,
155 initial="Ethernet1",
156 max_length=_INTERFACE_NAME_MAX_LENGTH,
157 label="{base} (interface name)",
158 help_text="Device rules derive {port} from the segment after the last slash, or the full name without a slash.",
159 widget=forms.TextInput(attrs={"class": "form-control"}),
160 )
162 def clean(self):
163 """Validate rule matching, templates, and the selected breakout topology."""
164 cleaned_data = super().clean()
165 self._clean_rule(cleaned_data)
166 module_type_is_regex = cleaned_data.get("module_type_is_regex", False)
167 module_type = cleaned_data.get("module_type")
168 module_type_pattern = cleaned_data.get("module_type_pattern", "")
170 if cleaned_data.get("applies_to_device_interfaces"):
171 cleaned_data["module_type_is_regex"] = False
172 cleaned_data["parent_module_type"] = None
173 if module_type:
174 self.add_error("module_type", "Module type must be empty for device-level interface rules.")
175 if cleaned_data.get("var_vc_position") is None and "var_vc_position" not in self.errors:
176 self.add_error("var_vc_position", "A virtual-chassis position is required for a device rule.")
177 if module_type_pattern:
178 self._clean_pattern(module_type_pattern)
179 elif module_type_is_regex:
180 if not module_type_pattern:
181 self.add_error("module_type_pattern", "A regex pattern is required when regex mode is enabled.")
182 else:
183 self._clean_pattern(module_type_pattern)
184 if module_type:
185 self.add_error("module_type", "Module Type (exact) must be empty when regex mode is enabled.")
186 else:
187 if module_type_pattern:
188 self.add_error("module_type_pattern", "Module Type Pattern must be empty when regex mode is disabled.")
190 return cleaned_data
192 def _clean_pattern(self, pattern):
193 """Report pattern compile errors on their form fields."""
194 from .regex_safety import compile_module_type_pattern
196 try:
197 compile_module_type_pattern(pattern)
198 except ValidationError as exc:
199 for field, messages in exc.message_dict.items():
200 self.add_error(field, messages)
202 def _clean_rule(self, cleaned_data):
203 """Validate the templates and topology for the selected rule kind."""
204 try:
205 validate_rule(
206 breakout_mode=cleaned_data.get("breakout_mode") or BreakoutModeChoices.FLAT,
207 channel_count=cleaned_data.get("channel_count") or 0,
208 name_template=cleaned_data.get("name_template") or "",
209 parent_name_template=cleaned_data.get("parent_name_template") or "",
210 applies_to_device_interfaces=cleaned_data.get("applies_to_device_interfaces", False),
211 )
212 except ValidationError as exc:
213 for field, messages in exc.message_dict.items():
214 self.add_error(field, messages)
216 def clean_breakout_mode(self):
217 """Return the flat topology when the field is left blank."""
218 return self.cleaned_data.get("breakout_mode") or BreakoutModeChoices.FLAT
220 def clean_channel_count(self):
221 """Return 0 when the field is blank or None."""
222 return self.cleaned_data.get("channel_count") or 0
224 def clean_channel_start(self):
225 """Return 0 when the field is blank or None."""
226 return self.cleaned_data.get("channel_start") or 0
229class InterfaceNameRuleForm(NetBoxModelForm):
230 """Add/edit form for InterfaceNameRule.
232 Priority is auto-computed from the rule fields — it cannot be set manually.
233 Scope fields (parent_module_type, device_type, platform) raise the priority score:
234 parent_module_type +400, device_type +200, platform +100 (for regex rules).
235 Exact FK rules always outrank regex rules (score 1000+ vs max 955).
236 """
238 class Meta:
239 model = InterfaceNameRule
240 fields = [
241 "module_type",
242 "module_type_pattern",
243 "module_type_is_regex",
244 "parent_module_type",
245 "device_type",
246 "platform",
247 "name_template",
248 "parent_name_template",
249 "breakout_mode",
250 "channel_count",
251 "channel_start",
252 "description",
253 "enabled",
254 "applies_to_device_interfaces",
255 ]
256 help_texts = {
257 "parent_module_type": (
258 "Optional. Restricts this rule to modules installed inside the given parent module type. "
259 "Setting this raises the priority score by 400 (regex) or keeps exact priority at 1000+. "
260 "Must be empty for device-level interface rules."
261 ),
262 "device_type": (
263 "Optional. Restricts this rule to modules installed in this device model. "
264 "Setting this raises the priority score by 200 (regex)."
265 ),
266 "platform": (
267 "Optional. Restricts this rule to devices running this OS/platform. "
268 "Setting this raises the priority score by 100 (regex)."
269 ),
270 "module_type_is_regex": (
271 "When checked, use a regex pattern instead of an exact FK. "
272 "Note: exact FK rules always outrank regex rules (exact score 1000–1007, regex max 955)."
273 ),
274 }
277class InterfaceNameRuleImportForm(NetBoxModelImportForm):
278 """CSV/YAML bulk-import form for InterfaceNameRule."""
280 # FK fields must declare to_field_name explicitly so YAML/CSV can reference
281 # objects by their natural key instead of numeric PK.
282 module_type = CSVModelChoiceField(
283 queryset=ModuleType.objects.all(),
284 required=False,
285 to_field_name="model",
286 help_text="Module type matched by its model name (e.g. SFP-10G-LR)",
287 )
288 parent_module_type = CSVModelChoiceField(
289 queryset=ModuleType.objects.all(),
290 required=False,
291 to_field_name="model",
292 help_text="Parent module type matched by its model name",
293 )
294 device_type = CSVModelChoiceField(
295 queryset=DeviceType.objects.all(),
296 required=False,
297 to_field_name="model",
298 help_text="Device type matched by its model name (e.g. ACX7024)",
299 )
300 platform = CSVModelChoiceField(
301 queryset=Platform.objects.all(),
302 required=False,
303 to_field_name="name",
304 help_text="Platform matched by its name (e.g. SONiC)",
305 )
307 class Meta:
308 model = InterfaceNameRule
309 fields = [
310 "module_type",
311 "module_type_pattern",
312 "module_type_is_regex",
313 "parent_module_type",
314 "device_type",
315 "platform",
316 "name_template",
317 "parent_name_template",
318 "breakout_mode",
319 "channel_count",
320 "channel_start",
321 "description",
322 "enabled",
323 "applies_to_device_interfaces",
324 ]
327class InterfaceNameRuleBulkEditForm(NetBoxModelBulkEditForm):
328 """Bulk-edit form for InterfaceNameRule.
330 Only fields that are meaningful to set across many rules at once are offered;
331 module_type and the regex-mode flags are per-rule and stay out of it.
332 """
334 model = InterfaceNameRule
336 parent_module_type = DynamicModelChoiceField(queryset=ModuleType.objects.all(), required=False)
337 device_type = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False)
338 platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False)
339 name_template = forms.CharField(max_length=255, required=False)
340 parent_name_template = forms.CharField(max_length=255, required=False)
341 # Blank first choice: a bulk edit posts every rendered field, so without a "no change" option a
342 # select rewrites the column on every selected rule.
343 breakout_mode = forms.ChoiceField(choices=add_blank_choice(BreakoutModeChoices), required=False, initial="")
344 channel_count = forms.IntegerField(min_value=0, required=False)
345 channel_start = forms.IntegerField(min_value=0, required=False)
346 description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
347 enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect())
349 fieldsets = (
350 FieldSet(
351 "name_template",
352 "parent_name_template",
353 "breakout_mode",
354 "channel_count",
355 "channel_start",
356 "enabled",
357 name="Rule",
358 ),
359 FieldSet("parent_module_type", "device_type", "platform", name="Scope"),
360 FieldSet("description", name="Description"),
361 )
362 nullable_fields = ("parent_module_type", "device_type", "platform", "parent_name_template", "description")
364 def clean(self):
365 """Refuse a bulk change before any selected rule is written."""
366 cleaned_data = super().clean()
367 if self.errors: 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 return cleaned_data
369 nullified = self.data.getlist("_nullify")
370 sets_parent = "parent_module_type" in self.changed_data and cleaned_data.get("parent_module_type")
371 for rule in cleaned_data.get("pk", ()):
372 if sets_parent and rule.applies_to_device_interfaces:
373 self.add_error(None, f"Rule {rule.pk} ({rule}): {DEVICE_RULE_PARENT_MODULE_TYPE_ERROR}")
374 fields = {
375 name: cleaned_data[name] if name in self.changed_data else getattr(rule, name)
376 for name in ("breakout_mode", "channel_count", "name_template", "parent_name_template")
377 }
378 if "parent_name_template" in nullified:
379 fields["parent_name_template"] = ""
380 try:
381 validate_rule(**fields, applies_to_device_interfaces=rule.applies_to_device_interfaces)
382 except ValidationError as exc:
383 for message in exc.messages:
384 self.add_error(None, f"Rule {rule.pk} ({rule}): {message}")
385 return cleaned_data
388class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm):
389 """Filter form for the InterfaceNameRule list view."""
391 q = forms.CharField(required=False, label="Search")
392 module_type_id = forms.ModelChoiceField(
393 queryset=ModuleType.objects.all(),
394 required=False,
395 label="Module Type",
396 )
397 module_type_is_regex = forms.NullBooleanField(required=False, label="Regex Mode")
398 applies_to_device_interfaces = forms.NullBooleanField(required=False, label="Device Interface Rules")
399 enabled = forms.NullBooleanField(required=False, label="Enabled")
400 module_type_pattern = forms.CharField(required=False, label="Pattern (contains)")
401 breakout_mode = forms.MultipleChoiceField(choices=BreakoutModeChoices, required=False, label="Breakout Mode")
402 parent_module_type_id = forms.ModelChoiceField(
403 queryset=ModuleType.objects.all(),
404 required=False,
405 label="Parent Module Type",
406 )
407 device_type_id = forms.ModelChoiceField(
408 queryset=DeviceType.objects.all(),
409 required=False,
410 label="Device Type",
411 )
412 platform_id = forms.ModelChoiceField(
413 queryset=Platform.objects.all(),
414 required=False,
415 label="Platform",
416 )
418 model = InterfaceNameRule