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 netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
8from utilities.forms.fields import CSVModelChoiceField
9
10from .models import InterfaceNameRule
11
12
13class RuleTestForm(forms.Form):
14 """Standalone form for previewing interface name rule output without saving."""
15
16 # --- Rule definition ---
17 module_type_is_regex = forms.BooleanField(
18 required=False,
19 label="Use Regex Pattern",
20 initial=False,
21 widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
22 )
23 module_type = forms.ModelChoiceField(
24 queryset=ModuleType.objects.all(),
25 required=False,
26 label="Module Type (exact)",
27 help_text="FK match — used when regex mode is off",
28 widget=forms.Select(attrs={"class": "form-select"}),
29 )
30 module_type_pattern = forms.CharField(
31 required=False,
32 label="Module Type Pattern (regex)",
33 help_text="Regex pattern matched against ModuleType.model via re.fullmatch()",
34 widget=forms.TextInput(attrs={"class": "form-control"}),
35 )
36 parent_module_type = forms.ModelChoiceField(
37 queryset=ModuleType.objects.all(),
38 required=False,
39 label="Parent Module Type",
40 help_text="Optional: scope to modules installed inside this parent module type",
41 widget=forms.Select(attrs={"class": "form-select"}),
42 )
43 device_type = forms.ModelChoiceField(
44 queryset=DeviceType.objects.all(),
45 required=False,
46 label="Device Type",
47 help_text="Optional: scope to devices of this hardware model",
48 widget=forms.Select(attrs={"class": "form-select"}),
49 )
50 platform = forms.ModelChoiceField(
51 queryset=Platform.objects.all(),
52 required=False,
53 label="Platform",
54 help_text="Optional: scope to devices running this software platform/OS (e.g. SONiC, EOS)",
55 widget=forms.Select(attrs={"class": "form-select"}),
56 )
57 name_template = forms.CharField(
58 required=True,
59 label="Name Template",
60 help_text="e.g. et-0/0/{bay_position} or {base}:{channel}",
61 widget=forms.TextInput(attrs={"class": "form-control"}),
62 )
63 channel_count = forms.IntegerField(
64 required=False,
65 initial=0,
66 min_value=0,
67 label="Channel Count",
68 help_text="0 = no breakout; > 0 generates one interface per channel",
69 widget=forms.NumberInput(attrs={"class": "form-control"}),
70 )
71 channel_start = forms.IntegerField(
72 required=False,
73 initial=0,
74 min_value=0,
75 label="Channel Start",
76 help_text="Starting channel index (0 for Juniper, varies for Cisco)",
77 widget=forms.NumberInput(attrs={"class": "form-control"}),
78 )
79
80 # --- Variable override fields ---
81 var_slot = forms.CharField(
82 required=False, initial="1", label="{slot}", widget=forms.TextInput(attrs={"class": "form-control"})
83 )
84 var_bay_position = forms.CharField(
85 required=False, initial="1", label="{bay_position}", widget=forms.TextInput(attrs={"class": "form-control"})
86 )
87 var_bay_position_num = forms.CharField(
88 required=False, initial="1", label="{bay_position_num}", widget=forms.TextInput(attrs={"class": "form-control"})
89 )
90 var_parent_bay_position = forms.CharField(
91 required=False,
92 initial="1",
93 label="{parent_bay_position}",
94 widget=forms.TextInput(attrs={"class": "form-control"}),
95 )
96 var_sfp_slot = forms.CharField(
97 required=False, initial="1", label="{sfp_slot}", widget=forms.TextInput(attrs={"class": "form-control"})
98 )
99 var_base = forms.CharField(
100 required=False,
101 initial="Ethernet1",
102 label="{base} (current interface name)",
103 widget=forms.TextInput(attrs={"class": "form-control"}),
104 )
105
106 def clean(self):
107 """Validate mutual exclusivity of regex/exact module-type fields."""
108 cleaned_data = super().clean()
109 module_type_is_regex = cleaned_data.get("module_type_is_regex", False)
110 module_type = cleaned_data.get("module_type")
111 module_type_pattern = cleaned_data.get("module_type_pattern", "")
112
113 if module_type_is_regex:
114 if not module_type_pattern:
115 self.add_error("module_type_pattern", "A regex pattern is required when regex mode is enabled.")
116 else:
117 try:
118 re.compile(module_type_pattern)
119 except re.error as exc:
120 self.add_error("module_type_pattern", f"Invalid regex: {exc}")
121 else:
122 from .models import _REDOS_PATTERN
123
124 if _REDOS_PATTERN.search(module_type_pattern):
125 self.add_error("module_type_pattern", "Pattern contains potentially unsafe nested quantifiers.")
126 if module_type:
127 self.add_error("module_type", "Module Type (exact) must be empty when regex mode is enabled.")
128 else:
129 if module_type_pattern:
130 self.add_error("module_type_pattern", "Module Type Pattern must be empty when regex mode is disabled.")
131
132 return cleaned_data
133
134 def clean_channel_count(self):
135 """Return 0 when the field is blank or None."""
136 return self.cleaned_data.get("channel_count") or 0
137
138 def clean_channel_start(self):
139 """Return 0 when the field is blank or None."""
140 return self.cleaned_data.get("channel_start") or 0
141
142
143class InterfaceNameRuleForm(NetBoxModelForm):
144 """Add/edit form for InterfaceNameRule.
145
146 Priority is auto-computed from the rule fields — it cannot be set manually.
147 Scope fields (parent_module_type, device_type, platform) raise the priority score:
148 parent_module_type +400, device_type +200, platform +100 (for regex rules).
149 Exact FK rules always outrank regex rules (score 1000+ vs max 955).
150 """
151
152 class Meta:
153 model = InterfaceNameRule
154 fields = [
155 "module_type",
156 "module_type_pattern",
157 "module_type_is_regex",
158 "parent_module_type",
159 "device_type",
160 "platform",
161 "name_template",
162 "channel_count",
163 "channel_start",
164 "description",
165 "enabled",
166 "applies_to_device_interfaces",
167 ]
168 help_texts = {
169 "parent_module_type": (
170 "Optional. Restricts this rule to modules installed inside the given parent module type. "
171 "Setting this raises the priority score by 400 (regex) or keeps exact priority at 1000+."
172 ),
173 "device_type": (
174 "Optional. Restricts this rule to modules installed in this device model. "
175 "Setting this raises the priority score by 200 (regex)."
176 ),
177 "platform": (
178 "Optional. Restricts this rule to devices running this OS/platform. "
179 "Setting this raises the priority score by 100 (regex)."
180 ),
181 "module_type_is_regex": (
182 "When checked, use a regex pattern instead of an exact FK. "
183 "Note: exact FK rules always outrank regex rules (exact score 1000–1007, regex max 955)."
184 ),
185 }
186
187
188class InterfaceNameRuleImportForm(NetBoxModelImportForm):
189 """CSV/YAML bulk-import form for InterfaceNameRule."""
190
191 # FK fields must declare to_field_name explicitly so YAML/CSV can reference
192 # objects by their natural key instead of numeric PK.
193 module_type = CSVModelChoiceField(
194 queryset=ModuleType.objects.all(),
195 required=False,
196 to_field_name="model",
197 help_text="Module type matched by its model name (e.g. SFP-10G-LR)",
198 )
199 parent_module_type = CSVModelChoiceField(
200 queryset=ModuleType.objects.all(),
201 required=False,
202 to_field_name="model",
203 help_text="Parent module type matched by its model name",
204 )
205 device_type = CSVModelChoiceField(
206 queryset=DeviceType.objects.all(),
207 required=False,
208 to_field_name="model",
209 help_text="Device type matched by its model name (e.g. ACX7024)",
210 )
211 platform = CSVModelChoiceField(
212 queryset=Platform.objects.all(),
213 required=False,
214 to_field_name="name",
215 help_text="Platform matched by its name (e.g. SONiC)",
216 )
217
218 class Meta:
219 model = InterfaceNameRule
220 fields = [
221 "module_type",
222 "module_type_pattern",
223 "module_type_is_regex",
224 "parent_module_type",
225 "device_type",
226 "platform",
227 "name_template",
228 "channel_count",
229 "channel_start",
230 "description",
231 "enabled",
232 "applies_to_device_interfaces",
233 ]
234
235
236class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm):
237 """Filter form for the InterfaceNameRule list view."""
238
239 q = forms.CharField(required=False, label="Search")
240 module_type_id = forms.ModelChoiceField(
241 queryset=ModuleType.objects.all(),
242 required=False,
243 label="Module Type",
244 )
245 module_type_is_regex = forms.NullBooleanField(required=False, label="Regex Mode")
246 applies_to_device_interfaces = forms.NullBooleanField(required=False, label="Device Interface Rules")
247 enabled = forms.NullBooleanField(required=False, label="Enabled")
248 module_type_pattern = forms.CharField(required=False, label="Pattern (contains)")
249 parent_module_type_id = forms.ModelChoiceField(
250 queryset=ModuleType.objects.all(),
251 required=False,
252 label="Parent Module Type",
253 )
254 device_type_id = forms.ModelChoiceField(
255 queryset=DeviceType.objects.all(),
256 required=False,
257 label="Device Type",
258 )
259 platform_id = forms.ModelChoiceField(
260 queryset=Platform.objects.all(),
261 required=False,
262 label="Platform",
263 )
264
265 model = InterfaceNameRule