Coverage for /home/runner/work/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox_interface_name_rules/forms.py: 100%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:16 +0000

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