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

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 

17 

18from .choices import BreakoutModeChoices 

19from .models import DEVICE_RULE_PARENT_MODULE_TYPE_ERROR, InterfaceNameRule 

20from .name_template import validate_rule 

21 

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 

28 

29 

30class RuleTestForm(forms.Form): 

31 """Standalone form for previewing interface name rule output without saving.""" 

32 

33 device_pattern_label = "Interface-name filter (RE2)" 

34 device_pattern_help_text = "Optional RE2 pattern matched against the complete interface name" 

35 

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 ) 

52 

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 ) 

130 

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 ) 

161 

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", "") 

169 

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.") 

189 

190 return cleaned_data 

191 

192 def _clean_pattern(self, pattern): 

193 """Report pattern compile errors on their form fields.""" 

194 from .regex_safety import compile_module_type_pattern 

195 

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) 

201 

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) 

215 

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 

219 

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 

223 

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 

227 

228 

229class InterfaceNameRuleForm(NetBoxModelForm): 

230 """Add/edit form for InterfaceNameRule. 

231 

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 """ 

237 

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 } 

275 

276 

277class InterfaceNameRuleImportForm(NetBoxModelImportForm): 

278 """CSV/YAML bulk-import form for InterfaceNameRule.""" 

279 

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 ) 

306 

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 ] 

325 

326 

327class InterfaceNameRuleBulkEditForm(NetBoxModelBulkEditForm): 

328 """Bulk-edit form for InterfaceNameRule. 

329 

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 """ 

333 

334 model = InterfaceNameRule 

335 

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()) 

348 

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") 

363 

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 

386 

387 

388class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm): 

389 """Filter form for the InterfaceNameRule list view.""" 

390 

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 ) 

417 

418 model = InterfaceNameRule