1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3import re
4
5from dcim.models import DeviceType, ModuleType, Platform
6from django import forms
7from django.core.exceptions import ValidationError
8from netbox.forms import (
9 NetBoxModelBulkEditForm,
10 NetBoxModelFilterSetForm,
11 NetBoxModelForm,
12 NetBoxModelImportForm,
13)
14from utilities.forms import add_blank_choice
15from utilities.forms.fields import CSVModelChoiceField, DynamicModelChoiceField
16from utilities.forms.rendering import FieldSet
17from utilities.forms.widgets import BulkEditNullBooleanSelect
18
19from .choices import BreakoutModeChoices
20from .models import InterfaceNameRule
21
22
23class RuleTestForm(forms.Form):
24 """Standalone form for previewing interface name rule output without saving."""
25
26 # --- Rule definition ---
27 module_type_is_regex = forms.BooleanField(
28 required=False,
29 label="Use Regex Pattern",
30 initial=False,
31 widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
32 )
33 module_type = forms.ModelChoiceField(
34 queryset=ModuleType.objects.all(),
35 required=False,
36 label="Module Type (exact)",
37 help_text="FK match — used when regex mode is off",
38 widget=forms.Select(attrs={"class": "form-select"}),
39 )
40 module_type_pattern = forms.CharField(
41 required=False,
42 label="Module Type Pattern (regex)",
43 help_text="Regex pattern matched against ModuleType.model via re.fullmatch()",
44 widget=forms.TextInput(attrs={"class": "form-control"}),
45 )
46 parent_module_type = forms.ModelChoiceField(
47 queryset=ModuleType.objects.all(),
48 required=False,
49 label="Parent Module Type",
50 help_text="Optional: scope to modules installed inside this parent module type",
51 widget=forms.Select(attrs={"class": "form-select"}),
52 )
53 device_type = forms.ModelChoiceField(
54 queryset=DeviceType.objects.all(),
55 required=False,
56 label="Device Type",
57 help_text="Optional: scope to devices of this hardware model",
58 widget=forms.Select(attrs={"class": "form-select"}),
59 )
60 platform = forms.ModelChoiceField(
61 queryset=Platform.objects.all(),
62 required=False,
63 label="Platform",
64 help_text="Optional: scope to devices running this software platform/OS (e.g. SONiC, EOS)",
65 widget=forms.Select(attrs={"class": "form-select"}),
66 )
67 name_template = forms.CharField(
68 required=True,
69 label="Name Template",
70 help_text="e.g. et-0/0/{bay_position} or {base}:{channel}",
71 widget=forms.TextInput(attrs={"class": "form-control"}),
72 )
73 parent_name_template = forms.CharField(
74 required=False,
75 label="Parent Name Template",
76 help_text="Channelized mode only: name for the parent interface, e.g. et-0/0/{bay_position}",
77 widget=forms.TextInput(attrs={"class": "form-control"}),
78 )
79 breakout_mode = forms.ChoiceField(
80 required=False,
81 choices=BreakoutModeChoices,
82 initial=BreakoutModeChoices.FLAT,
83 label="Breakout Mode",
84 help_text="flat = sibling interfaces; channelized = one parent with channel subinterfaces",
85 widget=forms.Select(attrs={"class": "form-select"}),
86 )
87 channel_count = forms.IntegerField(
88 required=False,
89 initial=0,
90 min_value=0,
91 label="Channel Count",
92 help_text="0 = no breakout; > 0 generates one interface per channel",
93 widget=forms.NumberInput(attrs={"class": "form-control"}),
94 )
95 channel_start = forms.IntegerField(
96 required=False,
97 initial=0,
98 min_value=0,
99 label="Channel Start",
100 help_text="Starting channel index (0 for Juniper, varies for Cisco)",
101 widget=forms.NumberInput(attrs={"class": "form-control"}),
102 )
103
104 # --- Variable override fields ---
105 var_slot = forms.CharField(
106 required=False, initial="1", label="{slot}", widget=forms.TextInput(attrs={"class": "form-control"})
107 )
108 var_bay_position = forms.CharField(
109 required=False, initial="1", label="{bay_position}", widget=forms.TextInput(attrs={"class": "form-control"})
110 )
111 var_bay_position_num = forms.CharField(
112 required=False, initial="1", label="{bay_position_num}", widget=forms.TextInput(attrs={"class": "form-control"})
113 )
114 var_parent_bay_position = forms.CharField(
115 required=False,
116 initial="1",
117 label="{parent_bay_position}",
118 widget=forms.TextInput(attrs={"class": "form-control"}),
119 )
120 var_sfp_slot = forms.CharField(
121 required=False, initial="1", label="{sfp_slot}", widget=forms.TextInput(attrs={"class": "form-control"})
122 )
123 var_base = forms.CharField(
124 required=False,
125 initial="Ethernet1",
126 label="{base} (current interface name)",
127 widget=forms.TextInput(attrs={"class": "form-control"}),
128 )
129
130 def clean(self):
131 """Validate regex/exact module-type exclusivity and the breakout topology."""
132 cleaned_data = super().clean()
133 self._clean_breakout_topology(cleaned_data)
134 module_type_is_regex = cleaned_data.get("module_type_is_regex", False)
135 module_type = cleaned_data.get("module_type")
136 module_type_pattern = cleaned_data.get("module_type_pattern", "")
137
138 if module_type_is_regex:
139 if not module_type_pattern:
140 self.add_error("module_type_pattern", "A regex pattern is required when regex mode is enabled.")
141 else:
142 try:
143 re.compile(module_type_pattern)
144 except re.error as exc:
145 self.add_error("module_type_pattern", f"Invalid regex: {exc}")
146 else:
147 from .models import _REDOS_PATTERN
148
149 if _REDOS_PATTERN.search(module_type_pattern):
150 self.add_error("module_type_pattern", "Pattern contains potentially unsafe nested quantifiers.")
151 if module_type:
152 self.add_error("module_type", "Module Type (exact) must be empty when regex mode is enabled.")
153 else:
154 if module_type_pattern:
155 self.add_error("module_type_pattern", "Module Type Pattern must be empty when regex mode is disabled.")
156
157 return cleaned_data
158
159 def _clean_breakout_topology(self, cleaned_data):
160 """Reject mode/channel-count/parent-template combinations the model would refuse on save."""
161 from .models import _validate_breakout_topology
162
163 try:
164 _validate_breakout_topology(
165 cleaned_data.get("breakout_mode") or BreakoutModeChoices.FLAT,
166 cleaned_data.get("channel_count") or 0,
167 cleaned_data.get("parent_name_template") or "",
168 )
169 except ValidationError as exc:
170 for field, messages in exc.message_dict.items():
171 self.add_error(field, messages)
172
173 def clean_breakout_mode(self):
174 """Return the flat topology when the field is left blank."""
175 return self.cleaned_data.get("breakout_mode") or BreakoutModeChoices.FLAT
176
177 def clean_channel_count(self):
178 """Return 0 when the field is blank or None."""
179 return self.cleaned_data.get("channel_count") or 0
180
181 def clean_channel_start(self):
182 """Return 0 when the field is blank or None."""
183 return self.cleaned_data.get("channel_start") or 0
184
185
186class InterfaceNameRuleForm(NetBoxModelForm):
187 """Add/edit form for InterfaceNameRule.
188
189 Priority is auto-computed from the rule fields — it cannot be set manually.
190 Scope fields (parent_module_type, device_type, platform) raise the priority score:
191 parent_module_type +400, device_type +200, platform +100 (for regex rules).
192 Exact FK rules always outrank regex rules (score 1000+ vs max 955).
193 """
194
195 class Meta:
196 model = InterfaceNameRule
197 fields = [
198 "module_type",
199 "module_type_pattern",
200 "module_type_is_regex",
201 "parent_module_type",
202 "device_type",
203 "platform",
204 "name_template",
205 "parent_name_template",
206 "breakout_mode",
207 "channel_count",
208 "channel_start",
209 "description",
210 "enabled",
211 "applies_to_device_interfaces",
212 ]
213 help_texts = {
214 "parent_module_type": (
215 "Optional. Restricts this rule to modules installed inside the given parent module type. "
216 "Setting this raises the priority score by 400 (regex) or keeps exact priority at 1000+."
217 ),
218 "device_type": (
219 "Optional. Restricts this rule to modules installed in this device model. "
220 "Setting this raises the priority score by 200 (regex)."
221 ),
222 "platform": (
223 "Optional. Restricts this rule to devices running this OS/platform. "
224 "Setting this raises the priority score by 100 (regex)."
225 ),
226 "module_type_is_regex": (
227 "When checked, use a regex pattern instead of an exact FK. "
228 "Note: exact FK rules always outrank regex rules (exact score 1000–1007, regex max 955)."
229 ),
230 }
231
232
233class InterfaceNameRuleImportForm(NetBoxModelImportForm):
234 """CSV/YAML bulk-import form for InterfaceNameRule."""
235
236 # FK fields must declare to_field_name explicitly so YAML/CSV can reference
237 # objects by their natural key instead of numeric PK.
238 module_type = CSVModelChoiceField(
239 queryset=ModuleType.objects.all(),
240 required=False,
241 to_field_name="model",
242 help_text="Module type matched by its model name (e.g. SFP-10G-LR)",
243 )
244 parent_module_type = CSVModelChoiceField(
245 queryset=ModuleType.objects.all(),
246 required=False,
247 to_field_name="model",
248 help_text="Parent module type matched by its model name",
249 )
250 device_type = CSVModelChoiceField(
251 queryset=DeviceType.objects.all(),
252 required=False,
253 to_field_name="model",
254 help_text="Device type matched by its model name (e.g. ACX7024)",
255 )
256 platform = CSVModelChoiceField(
257 queryset=Platform.objects.all(),
258 required=False,
259 to_field_name="name",
260 help_text="Platform matched by its name (e.g. SONiC)",
261 )
262
263 class Meta:
264 model = InterfaceNameRule
265 fields = [
266 "module_type",
267 "module_type_pattern",
268 "module_type_is_regex",
269 "parent_module_type",
270 "device_type",
271 "platform",
272 "name_template",
273 "parent_name_template",
274 "breakout_mode",
275 "channel_count",
276 "channel_start",
277 "description",
278 "enabled",
279 "applies_to_device_interfaces",
280 ]
281
282
283class InterfaceNameRuleBulkEditForm(NetBoxModelBulkEditForm):
284 """Bulk-edit form for InterfaceNameRule.
285
286 Only fields that are meaningful to set across many rules at once are offered;
287 module_type and the regex-mode flags are per-rule and stay out of it.
288 """
289
290 model = InterfaceNameRule
291
292 parent_module_type = DynamicModelChoiceField(queryset=ModuleType.objects.all(), required=False)
293 device_type = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False)
294 platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False)
295 name_template = forms.CharField(max_length=255, required=False)
296 parent_name_template = forms.CharField(max_length=255, required=False)
297 # Blank first choice: a bulk edit posts every rendered field, so without a "no change" option a
298 # select rewrites the column on every selected rule.
299 breakout_mode = forms.ChoiceField(choices=add_blank_choice(BreakoutModeChoices), required=False, initial="")
300 channel_count = forms.IntegerField(min_value=0, required=False)
301 channel_start = forms.IntegerField(min_value=0, required=False)
302 description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
303 enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect())
304
305 fieldsets = (
306 FieldSet(
307 "name_template",
308 "parent_name_template",
309 "breakout_mode",
310 "channel_count",
311 "channel_start",
312 "enabled",
313 name="Rule",
314 ),
315 FieldSet("parent_module_type", "device_type", "platform", name="Scope"),
316 FieldSet("description", name="Description"),
317 )
318 nullable_fields = ("parent_module_type", "device_type", "platform", "parent_name_template", "description")
319
320
321class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm):
322 """Filter form for the InterfaceNameRule list view."""
323
324 q = forms.CharField(required=False, label="Search")
325 module_type_id = forms.ModelChoiceField(
326 queryset=ModuleType.objects.all(),
327 required=False,
328 label="Module Type",
329 )
330 module_type_is_regex = forms.NullBooleanField(required=False, label="Regex Mode")
331 applies_to_device_interfaces = forms.NullBooleanField(required=False, label="Device Interface Rules")
332 enabled = forms.NullBooleanField(required=False, label="Enabled")
333 module_type_pattern = forms.CharField(required=False, label="Pattern (contains)")
334 breakout_mode = forms.MultipleChoiceField(choices=BreakoutModeChoices, required=False, label="Breakout Mode")
335 parent_module_type_id = forms.ModelChoiceField(
336 queryset=ModuleType.objects.all(),
337 required=False,
338 label="Parent Module Type",
339 )
340 device_type_id = forms.ModelChoiceField(
341 queryset=DeviceType.objects.all(),
342 required=False,
343 label="Device Type",
344 )
345 platform_id = forms.ModelChoiceField(
346 queryset=Platform.objects.all(),
347 required=False,
348 label="Platform",
349 )
350
351 model = InterfaceNameRule