Coverage for netbox_interface_name_rules / views.py: 95.71%

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

3import dataclasses 

4import logging 

5import re 

6 

7import yaml 

8from django.conf import settings 

9from django.contrib import messages 

10from django.core.exceptions import PermissionDenied, ValidationError 

11from django.http import JsonResponse 

12from django.shortcuts import redirect, render 

13from django.urls import reverse 

14from django.utils import timezone 

15from django.utils.http import url_has_allowed_host_and_scheme 

16from netbox.views import generic 

17from netbox.views.generic.base import BaseMultiObjectView 

18from utilities.views import register_model_view 

19 

20from .choices import BreakoutModeChoices 

21from .filters import InterfaceNameRuleFilterSet 

22from .forms import ( 

23 InterfaceNameRuleBulkEditForm, 

24 InterfaceNameRuleFilterForm, 

25 InterfaceNameRuleForm, 

26 InterfaceNameRuleImportForm, 

27 RuleTestForm, 

28) 

29from .models import InterfaceNameRule, csv_export_entry 

30from .name_template import NamingContext, variables_for_context 

31from .tables import InterfaceNameRuleTable 

32from .template_variable_reference import naming_context_reference, rule_tester_variable_rows, variable_reference_rows 

33from .transactions import atomic_with_events, write_scope 

34 

35logger = logging.getLogger(__name__) 

36 

37try: 

38 _plugins_config = getattr(settings, "PLUGINS_CONFIG", {}) 

39 APPLY_BATCH_LIMIT = max(1, int(_plugins_config.get("netbox_interface_name_rules", {}).get("apply_batch_limit", 50))) 

40except (ValueError, TypeError): 

41 APPLY_BATCH_LIMIT = 50 

42 

43 

44def _failure_text(error): 

45 """Return what the operator reads about *error*: each channel to rename back, or else the error type.""" 

46 from .family import ChannelReconciliationError 

47 

48 return str(error) if isinstance(error, ChannelReconciliationError) else type(error).__name__ 

49 

50 

51@dataclasses.dataclass 

52class RulePreview: 

53 """Lightweight stand-in for InterfaceNameRule used in the test/preview view.""" 

54 

55 module_type_is_regex: bool 

56 module_type_pattern: str 

57 module_type: object 

58 parent_module_type: object 

59 device_type: object 

60 platform: object 

61 name_template: str 

62 channel_count: int 

63 channel_start: int 

64 parent_name_template: str = "" 

65 breakout_mode: str = BreakoutModeChoices.FLAT 

66 applies_to_device_interfaces: bool = False 

67 

68 

69try: 

70 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport 

71 

72 class _YAMLOnlyExport(BulkExport): 

73 """Export action that only offers YAML (no CSV "Current View" option).""" 

74 

75 template_name = "netbox_interface_name_rules/buttons/export_yaml_only.html" 

76 

77 # No BulkRename: rules have no name field, and its button template renders nothing anyway. 

78 _LIST_VIEW_ACTIONS: tuple = (AddObject, BulkImport, _YAMLOnlyExport, BulkEdit, BulkDelete) 

79except ImportError: 

80 _LIST_VIEW_ACTIONS = None 

81 

82 

83class InterfaceNameRuleListView(generic.ObjectListView): 

84 """List view for InterfaceNameRule.""" 

85 

86 queryset = InterfaceNameRule.objects.all() 

87 table = InterfaceNameRuleTable 

88 filterset = InterfaceNameRuleFilterSet 

89 filterset_form = InterfaceNameRuleFilterForm 

90 template_name = "netbox_interface_name_rules/interfacenamerule_list.html" 

91 if _LIST_VIEW_ACTIONS is not None: 91 ↛ 94line 91 didn't jump to line 94 because the condition on line 91 was always true

92 actions = _LIST_VIEW_ACTIONS 

93 

94 def get_extra_context(self, request): 

95 """Add the catalogue-backed template-variable reference.""" 

96 return { 

97 "template_naming_contexts": naming_context_reference(), 

98 "template_variable_rows": variable_reference_rows(), 

99 } 

100 

101 def export_yaml(self): 

102 """Export all rules as a single YAML list (overrides NetBox's per-object concatenation).""" 

103 data = [ 

104 csv_export_entry(InterfaceNameRule.csv_headers, rule.to_csv()) 

105 for rule in self.queryset.order_by("pk").select_related( 

106 "module_type", "parent_module_type", "device_type", "platform" 

107 ) 

108 ] 

109 return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) 

110 

111 

112def _submitted(request, field): 

113 """Return a submitted rule field, wherever the form put it. 

114 

115 NetBox 4.4 gave the quick-add modal a ``quickadd`` form prefix; 4.3 posts the fields under 

116 their own names, so the prefix cannot be assumed from the quick-add marker alone. 

117 """ 

118 return request.POST.get(f"quickadd-{field}") or request.POST.get(field) 

119 

120 

121class ZeroMatchPatternWarningMixin: 

122 """Warn when a saved regex rule matches no module type that exists now. 

123 

124 Saving still succeeds, because a rule may name a module type that is not in the database yet. 

125 NetBox's edit view does its work in ``post`` and offers no post-save hook, so this wraps it. 

126 A quick add answers with a modal fragment that renders no messages, so that warning reaches 

127 the operator on the next page they load, and it answers a successful save with 200 rather 

128 than a redirect, which is why a save is recognised from the database and not the response. 

129 """ 

130 

131 def post(self, request, *args, **kwargs): 

132 """Save through the parent view, then report a pattern that selects nothing.""" 

133 pattern = (_submitted(request, "module_type_pattern") or "").strip() 

134 regex_rule = pattern and _submitted(request, "module_type_is_regex") 

135 # A device-interface rule filters interface names, so no module type is expected to match. 

136 device_rule = _submitted(request, "applies_to_device_interfaces") 

137 started = timezone.now() 

138 response = super().post(request, *args, **kwargs) 

139 if regex_rule and not device_rule: 

140 self._warn_if_pattern_matches_nothing(request, pattern, started) 

141 return response 

142 

143 def _warn_if_pattern_matches_nothing(self, request, pattern, started): 

144 # Only a rule written by this request saved; a rejected form leaves nothing to report on. 

145 saved = self.queryset.model.objects.filter( 

146 module_type_pattern=pattern, module_type_is_regex=True, last_updated__gte=started 

147 ) 

148 if not saved.exists(): 

149 return 

150 from .rule_selection import module_types_matching_pattern 

151 

152 try: 

153 matched = module_types_matching_pattern(pattern) 

154 except ValidationError: 

155 return 

156 if not matched: 

157 messages.warning( 

158 request, 

159 f"Pattern {pattern!r} matches no module type, so this rule will not fire. " 

160 "The field takes a regular expression, not a glob: '*' repeats the character " 

161 "before it, so 'GLC-T*' does not match 'GLC-TE'. Use 'GLC-T.*'.", 

162 ) 

163 

164 

165class InterfaceNameRuleCreateView(ZeroMatchPatternWarningMixin, generic.ObjectEditView): 

166 """Create view for InterfaceNameRule.""" 

167 

168 queryset = InterfaceNameRule.objects.all() 

169 form = InterfaceNameRuleForm 

170 

171 

172@register_model_view(InterfaceNameRule, "bulk_import", path="import", detail=False) 

173class InterfaceNameRuleBulkImportView(generic.BulkImportView): 

174 """Bulk import view for InterfaceNameRule.""" 

175 

176 queryset = InterfaceNameRule.objects.all() 

177 model_form = InterfaceNameRuleImportForm 

178 

179 

180class InterfaceNameRuleView(generic.ObjectView): 

181 """Detail view for InterfaceNameRule.""" 

182 

183 queryset = InterfaceNameRule.objects.all() 

184 

185 

186class InterfaceNameRuleEditView(ZeroMatchPatternWarningMixin, generic.ObjectEditView): 

187 """Edit view for InterfaceNameRule.""" 

188 

189 queryset = InterfaceNameRule.objects.all() 

190 form = InterfaceNameRuleForm 

191 

192 

193class InterfaceNameRuleDeleteView(generic.ObjectDeleteView): 

194 """Delete view for InterfaceNameRule.""" 

195 

196 queryset = InterfaceNameRule.objects.all() 

197 

198 

199class InterfaceNameRuleBulkEditView(generic.BulkEditView): 

200 """Bulk edit view for InterfaceNameRule.""" 

201 

202 queryset = InterfaceNameRule.objects.all() 

203 filterset = InterfaceNameRuleFilterSet 

204 table = InterfaceNameRuleTable 

205 form = InterfaceNameRuleBulkEditForm 

206 

207 

208class InterfaceNameRuleBulkDeleteView(generic.BulkDeleteView): 

209 """Bulk delete view for InterfaceNameRule.""" 

210 

211 queryset = InterfaceNameRule.objects.all() 

212 table = InterfaceNameRuleTable 

213 

214 

215class InterfaceNameRuleChangeLogView(generic.ObjectChangeLogView): 

216 """Change-log view for InterfaceNameRule.""" 

217 

218 queryset = InterfaceNameRule.objects.all() 

219 

220 

221class InterfaceNameRuleDuplicateView(generic.ObjectView): 

222 """Redirect to the add view pre-populated with a clone of the given rule.""" 

223 

224 queryset = InterfaceNameRule.objects.all() 

225 

226 def get(self, request, **kwargs): 

227 """Redirect to the add view pre-populated with fields cloned from the given rule.""" 

228 from utilities.querydict import prepare_cloned_fields 

229 

230 rule = self.get_object(**kwargs) 

231 params = prepare_cloned_fields(rule) 

232 url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add") 

233 return redirect(f"{url}?{params.urlencode()}") 

234 

235 

236class RuleTestView(BaseMultiObjectView): 

237 """Live-preview a name template with user-supplied variable values and optional DB lookup.""" 

238 

239 queryset = InterfaceNameRule.objects.all() 

240 template_name = "netbox_interface_name_rules/rule_test.html" 

241 

242 def get_required_permission(self): 

243 """Return the permission required to access the rule test tool.""" 

244 return "netbox_interface_name_rules.add_interfacenamerule" 

245 

246 def get(self, request): 

247 """Render the test form, pre-populated from rule_id query param if given.""" 

248 initial = {} 

249 loaded_rule = None 

250 rule_id = request.GET.get("rule_id") 

251 can_view = request.user.has_perm("netbox_interface_name_rules.view_interfacenamerule") 

252 if rule_id and not can_view: 

253 messages.warning(request, "You do not have permission to load an existing rule.") 

254 if rule_id and can_view: 

255 try: 

256 loaded_rule = ( 

257 InterfaceNameRule.objects.restrict(request.user, "view") 

258 .select_related("module_type", "parent_module_type", "device_type", "platform") 

259 .get(pk=int(rule_id)) 

260 ) 

261 initial = { 

262 "applies_to_device_interfaces": loaded_rule.applies_to_device_interfaces, 

263 "name_template": loaded_rule.name_template, 

264 "parent_name_template": loaded_rule.parent_name_template, 

265 "breakout_mode": loaded_rule.breakout_mode, 

266 "module_type_is_regex": loaded_rule.module_type_is_regex, 

267 "module_type": loaded_rule.module_type, 

268 "module_type_pattern": loaded_rule.module_type_pattern, 

269 "parent_module_type": loaded_rule.parent_module_type, 

270 "device_type": loaded_rule.device_type, 

271 "platform": loaded_rule.platform, 

272 "channel_count": loaded_rule.channel_count, 

273 "channel_start": loaded_rule.channel_start, 

274 } 

275 except (InterfaceNameRule.DoesNotExist, ValueError): 

276 pass 

277 return render( 

278 request, 

279 self.template_name, 

280 { 

281 "form": RuleTestForm(initial=initial), 

282 "loaded_rule": loaded_rule, 

283 **self._variable_reference(), 

284 }, 

285 ) 

286 

287 def post(self, request): 

288 """Evaluate the submitted template and return a preview or redirect to save.""" 

289 form = RuleTestForm(request.POST) 

290 preview_results = None 

291 db_preview = None 

292 db_total = 0 

293 error = None 

294 action = request.POST.get("action", "check") 

295 

296 if form.is_valid(): 

297 cd = form.cleaned_data 

298 if action == "save_rule": 

299 return self._handle_save_rule(request, cd) 

300 preview_results, error = self._evaluate_template_preview(cd) 

301 db_preview, db_total, db_error = self._fetch_db_preview(cd) 

302 if db_error and not error: 

303 error = db_error 

304 

305 return render( 

306 request, 

307 self.template_name, 

308 { 

309 "form": form, 

310 "preview_results": preview_results, 

311 "db_preview": db_preview, 

312 "db_total": db_total, 

313 "error": error, 

314 **self._variable_reference(), 

315 }, 

316 ) 

317 

318 @staticmethod 

319 def _variable_reference(): 

320 device_variables = {variable.name for variable in variables_for_context(NamingContext.DEVICE_INTERFACE)} 

321 return { 

322 "module_only_variable_fields": { 

323 f"var_{variable.name}" 

324 for variable in variables_for_context(NamingContext.MODULE_MEMBER) 

325 if variable.name not in device_variables 

326 }, 

327 "module_variable_rows": rule_tester_variable_rows(NamingContext.MODULE_MEMBER), 

328 "device_variable_rows": rule_tester_variable_rows(NamingContext.DEVICE_INTERFACE), 

329 } 

330 

331 def _find_existing_rule(self, cd, user=None): 

332 """Return the first existing rule matching the form data, or None.""" 

333 module_type_is_regex = cd.get("module_type_is_regex", False) 

334 qs = InterfaceNameRule.objects.restrict(user, "view") if user else InterfaceNameRule.objects.all() 

335 device_rule = cd.get("applies_to_device_interfaces", False) 

336 qs = qs.filter(applies_to_device_interfaces=device_rule) 

337 if device_rule: 

338 qs = qs.filter(module_type_pattern=cd.get("module_type_pattern", "")) 

339 elif module_type_is_regex: 

340 qs = qs.filter(module_type_is_regex=True, module_type_pattern=cd.get("module_type_pattern", "")) 

341 else: 

342 qs = qs.filter(module_type_is_regex=False, module_type=cd.get("module_type")) 

343 for field in ("parent_module_type", "device_type", "platform"): 

344 val = cd.get(field) 

345 qs = qs.filter(**{field: val}) if val else qs.filter(**{f"{field}__isnull": True}) 

346 return qs.first() 

347 

348 def _handle_save_rule(self, request, cd): 

349 """Find an existing matching rule or redirect to the add-rule form with pre-filled params.""" 

350 from urllib.parse import urlencode 

351 

352 name_template = cd["name_template"] 

353 channel_count = cd.get("channel_count") or 0 

354 channel_start = cd.get("channel_start") or 0 

355 module_type_is_regex = cd.get("module_type_is_regex", False) 

356 module_type = cd.get("module_type") 

357 

358 # Skip duplicate detection when the user lacks view permission — we cannot 

359 # query existing rules without it, so add-only users always land on the 

360 # create form (potentially allowing duplicates). 

361 if request.user.has_perm("netbox_interface_name_rules.view_interfacenamerule"): 

362 existing = self._find_existing_rule(cd, request.user) 

363 if existing: 

364 messages.info( 

365 request, 

366 f"A matching rule already exists (#{existing.pk}). Redirecting to edit it.", 

367 ) 

368 return redirect( 

369 reverse("plugins:netbox_interface_name_rules:interfacenamerule_edit", args=[existing.pk]) 

370 ) 

371 

372 params = { 

373 "name_template": name_template, 

374 "parent_name_template": cd.get("parent_name_template", ""), 

375 "breakout_mode": cd.get("breakout_mode") or BreakoutModeChoices.FLAT, 

376 "module_type_is_regex": "on" if module_type_is_regex else "", 

377 "applies_to_device_interfaces": "on" if cd.get("applies_to_device_interfaces") else "", 

378 "channel_count": channel_count, 

379 "channel_start": channel_start, 

380 } 

381 if module_type_is_regex or cd.get("applies_to_device_interfaces"): 

382 params["module_type_pattern"] = cd.get("module_type_pattern", "") 

383 elif module_type: 

384 params["module_type"] = module_type.pk 

385 for field in ("parent_module_type", "device_type", "platform"): 

386 val = cd.get(field) 

387 if val: 

388 params[field] = val.pk 

389 add_url = reverse("plugins:netbox_interface_name_rules:interfacenamerule_add") 

390 return redirect(f"{add_url}?{urlencode(params)}") 

391 

392 def _evaluate_template_preview(self, cd): 

393 """Evaluate the rule's templates against form variables; return (preview_results, error). 

394 

395 Each row carries the role the DB preview uses — ``parent``, ``channel`` or ``interface`` — 

396 so a channelized rule shows the parent it creates alongside the channels under it. 

397 """ 

398 from .name_template import UnavailableVariableError, evaluate_name_template 

399 

400 name_template = cd["name_template"] 

401 channel_count = cd.get("channel_count") or 0 

402 channel_start = cd.get("channel_start") or 0 

403 variables = self._template_preview_variables(cd) 

404 

405 def row(result, role, channel=None): 

406 """Describe one previewed name.""" 

407 return { 

408 "source": variables["base"], 

409 "channel": channel, 

410 "result": result, 

411 "role": role, 

412 "port": variables.get("port"), 

413 } 

414 

415 try: 

416 if channel_count > 0 and not cd.get("applies_to_device_interfaces"): 

417 preview_results = [] 

418 if cd.get("breakout_mode") == BreakoutModeChoices.CHANNELIZED: 

419 parent_template = cd.get("parent_name_template") or "" 

420 parent_name = ( 

421 evaluate_name_template(parent_template, variables) if parent_template else variables["base"] 

422 ) 

423 preview_results.append(row(parent_name, "parent")) 

424 for ch in range(channel_count): 

425 channel = str(channel_start + ch) 

426 preview_results.append( 

427 row( 

428 evaluate_name_template(name_template, {**variables, "channel": channel}), "channel", channel 

429 ) 

430 ) 

431 else: 

432 preview_results = [row(evaluate_name_template(name_template, variables), "interface")] 

433 except UnavailableVariableError as exc: 

434 return None, str(exc) 

435 except Exception as exc: 

436 logger.exception("Template evaluation error") 

437 return None, type(exc).__name__ 

438 else: 

439 return preview_results, None 

440 

441 @staticmethod 

442 def _template_preview_variables(cd): 

443 """Build the template variables from the form's sample values.""" 

444 from .naming import build_bay_chain_variables, build_device_interface_variables 

445 

446 if cd.get("applies_to_device_interfaces"): 

447 return build_device_interface_variables(cd.get("var_base") or "Ethernet1", cd.get("var_vc_position")) 

448 slot = cd.get("var_slot") or "1" 

449 bay_position = cd.get("var_bay_position") or "1" 

450 parent_bay_position = cd.get("var_parent_bay_position") or "1" 

451 variables = build_bay_chain_variables(slot, bay_position, parent_bay_position, cd.get("var_vc_position")) 

452 variables["base"] = cd.get("var_base") or "Ethernet1" 

453 return variables 

454 

455 def _fetch_db_preview(self, cd): 

456 """Run find_interfaces_for_rule against the DB; return (db_preview, db_total, error).""" 

457 from .engine import find_interfaces_for_rule 

458 

459 if cd.get("applies_to_device_interfaces"): 

460 return None, 0, None 

461 

462 name_template = cd["name_template"] 

463 channel_count = cd.get("channel_count") or 0 

464 channel_start = cd.get("channel_start") or 0 

465 module_type_is_regex = cd.get("module_type_is_regex", False) 

466 module_type = cd.get("module_type") 

467 module_type_pattern = cd.get("module_type_pattern", "") 

468 

469 if not ((module_type_is_regex and module_type_pattern) or (not module_type_is_regex and module_type)): 

470 return None, 0, None 

471 

472 fake = RulePreview( 

473 module_type_is_regex=module_type_is_regex, 

474 module_type_pattern=module_type_pattern, 

475 module_type=module_type, 

476 parent_module_type=cd.get("parent_module_type"), 

477 device_type=cd.get("device_type"), 

478 platform=cd.get("platform"), 

479 name_template=name_template, 

480 parent_name_template=cd.get("parent_name_template", ""), 

481 breakout_mode=cd.get("breakout_mode") or BreakoutModeChoices.FLAT, 

482 channel_count=channel_count, 

483 channel_start=channel_start, 

484 ) 

485 try: 

486 db_preview, db_total = find_interfaces_for_rule(fake, limit=100) 

487 except (re.error, ValueError) as exc: 

488 return [], 0, f"Invalid module type regex: {exc}" 

489 except Exception as exc: 

490 logger.exception("Unexpected error in find_interfaces_for_rule") 

491 return [], 0, f"Unexpected error: {type(exc).__name__}" 

492 else: 

493 return db_preview, db_total, None 

494 

495 

496class RuleApplyListView(BaseMultiObjectView): 

497 """Display all rules with buttons to preview/apply each one.""" 

498 

499 queryset = InterfaceNameRule.objects.all() 

500 template_name = "netbox_interface_name_rules/rule_apply.html" 

501 

502 def get_required_permission(self): 

503 """Return the permission required to access the apply-rules page.""" 

504 return "netbox_interface_name_rules.view_interfacenamerule" 

505 

506 def get(self, request): 

507 """Render the list of all rules with apply/preview buttons.""" 

508 rules = self.queryset.select_related("module_type", "parent_module_type", "device_type", "platform").order_by( 

509 "pk" 

510 ) 

511 return render(request, self.template_name, {"rules": rules, "batch_limit": APPLY_BATCH_LIMIT}) 

512 

513 

514class RuleApplicableView(generic.ObjectView): 

515 """Return JSON indicating whether a rule would rename at least one interface. 

516 

517 Called on demand from the Apply Rules page — NOT at page load — to avoid 

518 expensive full-scan queries blocking the initial render. 

519 """ 

520 

521 queryset = InterfaceNameRule.objects.all() 

522 

523 def get(self, request, **kwargs): 

524 """Return JSON {"applicable": bool} for the rule identified by pk.""" 

525 from .engine import has_applicable_interfaces 

526 

527 rule = self.get_object(**kwargs) 

528 try: 

529 applicable = has_applicable_interfaces(rule) 

530 except Exception as exc: 

531 logger.exception("applicability scan failed for rule %s", kwargs.get("pk")) 

532 return JsonResponse( 

533 {"applicable": None, "error": f"scan failed: {type(exc).__name__}"}, 

534 status=500, 

535 ) 

536 return JsonResponse({"applicable": applicable}) 

537 

538 

539class RuleApplyDetailView(generic.ObjectView): 

540 """Show a preview of changes for a specific rule and allow applying them.""" 

541 

542 queryset = InterfaceNameRule.objects.all() 

543 template_name = "netbox_interface_name_rules/rule_apply_detail.html" 

544 additional_permissions = ["dcim.change_interface"] 

545 

546 def get(self, request, **kwargs): 

547 """Render a preview of all interfaces that would be renamed by this rule.""" 

548 from .engine import find_convertible_families, find_interfaces_for_rule, supports_channelization 

549 from .family import ConversionPreview 

550 

551 rule = self.get_object(**kwargs) 

552 try: 

553 preview, total_checked = find_interfaces_for_rule(rule, limit=APPLY_BATCH_LIMIT) 

554 except (re.error, ValueError) as exc: 

555 logger.exception("Failed to compute preview for rule %s", rule) 

556 messages.error(request, f"Failed to compute preview: {exc}") 

557 preview, total_checked = [], 0 

558 except Exception as exc: 

559 logger.exception("Unexpected error computing preview for rule %s", rule) 

560 messages.error(request, f"Failed to compute preview: {type(exc).__name__}") 

561 preview, total_checked = [], 0 

562 # A conversion-scan failure must not blank the unrelated apply preview above. 

563 try: 

564 # Each family scanned costs a dry-run conversion, so the scan takes the same batch cap. 

565 with write_scope(): 

566 preview_conversions = find_convertible_families(rule, limit=APPLY_BATCH_LIMIT) 

567 except (re.error, ValueError) as exc: 

568 logger.exception("Failed to compute the conversion preview for rule %s", rule) 

569 messages.error(request, f"Failed to compute the conversion preview: {exc}") 

570 preview_conversions = ConversionPreview(candidates=()) 

571 except Exception as exc: 

572 logger.exception("Unexpected error computing the conversion preview for rule %s", rule) 

573 messages.error(request, f"Failed to compute the conversion preview: {type(exc).__name__}") 

574 preview_conversions = ConversionPreview(candidates=()) 

575 return render( 

576 request, 

577 self.template_name, 

578 { 

579 "rule": rule, 

580 "preview": preview, 

581 "conversions": preview_conversions.candidates, 

582 "conversions_available": any(candidate.convertible for candidate in preview_conversions.candidates), 

583 "conversions_have_more": preview_conversions.has_more, 

584 "total_checked": total_checked, 

585 "batch_limit": APPLY_BATCH_LIMIT, 

586 "has_more": len(preview) >= APPLY_BATCH_LIMIT, 

587 "can_apply": request.user.has_perm("dcim.change_interface"), 

588 # Families exist only where NetBox models channelization; older releases count plain interfaces. 

589 "families_supported": supports_channelization(), 

590 "checked_unit": "families" if supports_channelization() else "interfaces", 

591 }, 

592 ) 

593 

594 def _enqueue(self, request, rule, job_class, name): 

595 """Enqueue *job_class* against *rule* in the branch of *request*, and report the outcome to the operator.""" 

596 from .jobs import rule_job_kwargs 

597 

598 kwargs = rule_job_kwargs(rule.pk) 

599 try: 

600 # No instance=: the rule has no JobsMixin, so Job.full_clean() would refuse it. 

601 job = job_class.enqueue(name=name, user=request.user, **kwargs) 

602 messages.success(request, f"Background job enqueued (job #{job.pk}). Check Core → Jobs for status.") 

603 except Exception as e: 

604 logger.exception("Failed to enqueue background job for rule %s", rule) 

605 messages.error(request, f"Failed to enqueue background job: {type(e).__name__}") 

606 

607 def _convert(self, request, rule): 

608 """Convert the flat families the operator confirmed on this page.""" 

609 from .engine import convert_flat_families 

610 

611 convert_ids = [int(i) for i in request.POST.getlist("convert_ids") if i.isdigit()] 

612 if not convert_ids: 

613 messages.warning(request, "No families selected; nothing was converted.") 

614 return 

615 # Converting the first APPLY_BATCH_LIMIT of them would rewrite a set the operator never chose. 

616 if len(convert_ids) > APPLY_BATCH_LIMIT: 

617 messages.warning( 

618 request, 

619 f"{len(convert_ids)} families selected; synchronous conversion is limited to " 

620 f"{APPLY_BATCH_LIMIT} family(ies) per run, so nothing was converted. " 

621 f"Use Convert as Background Job to convert all of them.", 

622 ) 

623 return 

624 try: 

625 with write_scope(): 

626 outcome = convert_flat_families(rule, convert_ids) 

627 except Exception as e: 

628 logger.exception("Failed to convert families for rule %s", rule) 

629 messages.error(request, f"Failed to convert families: {_failure_text(e)}") 

630 return 

631 converted = len(outcome.changed_families) 

632 messages.success(request, f"Converted {converted} interface family(ies) to the channelized topology.") 

633 if outcome.blocked_families: 

634 messages.warning( 

635 request, f"{len(outcome.blocked_families)} family(ies) skipped. The plugin log names each one." 

636 ) 

637 

638 def post(self, request, **kwargs): 

639 """Apply or convert (foreground batch or background job) and redirect back.""" 

640 from .engine import apply_rule_to_existing 

641 

642 rule = self.get_object(**kwargs) 

643 action = request.POST.get("action", "apply") 

644 

645 if action == "convert": 

646 self._convert(request, rule) 

647 elif action == "convert_background": 

648 from .jobs import ConvertFlatFamiliesJob 

649 

650 self._enqueue(request, rule, ConvertFlatFamiliesJob, f"Convert flat families: {rule}") 

651 elif action == "background": 

652 from .jobs import ApplyRuleJob 

653 

654 self._enqueue(request, rule, ApplyRuleJob, f"Apply rule: {rule}") 

655 else: 

656 try: 

657 raw_ids = request.POST.getlist("interface_ids") 

658 interface_ids = [int(i) for i in raw_ids if i.isdigit()] 

659 if not interface_ids: 

660 messages.warning(request, "No interfaces selected; nothing was applied.") 

661 else: 

662 with write_scope(): 

663 outcome = apply_rule_to_existing(rule, limit=APPLY_BATCH_LIMIT, interface_ids=interface_ids) 

664 messages.success(request, f"Applied rule: {outcome.changed_count} interface(s) renamed.") 

665 if outcome.skipped_members: 

666 messages.warning( 

667 request, 

668 f"{len(outcome.skipped_members)} interface(s) skipped. The plugin log names each one.", 

669 ) 

670 except Exception as e: 

671 logger.exception("Failed to apply rule %s", rule) 

672 messages.error(request, f"Failed to apply rule {rule}: {_failure_text(e)}") 

673 

674 return redirect("plugins:netbox_interface_name_rules:interfacenamerule_apply_detail", pk=rule.pk) 

675 

676 

677@register_model_view(InterfaceNameRule, name="toggle", path="toggle") 

678class RuleToggleView(generic.ObjectView): 

679 """POST /rules/<pk>/toggle/ — flip the enabled flag on a rule.""" 

680 

681 queryset = InterfaceNameRule.objects.all() 

682 

683 def post(self, request, pk): 

684 """Toggle the enabled flag on the rule and return JSON or redirect.""" 

685 rule = self.get_object(pk=pk) 

686 if not request.user.has_perm("netbox_interface_name_rules.change_interfacenamerule"): 686 ↛ 687line 686 didn't jump to line 687 because the condition on line 686 was never true

687 if request.headers.get("X-Requested-With") == "XMLHttpRequest": 

688 return JsonResponse({"error": "Permission denied"}, status=403) 

689 raise PermissionDenied 

690 with atomic_with_events(): 

691 rule = InterfaceNameRule.objects.select_for_update().get(pk=rule.pk) 

692 rule.snapshot() 

693 rule.enabled = not rule.enabled 

694 rule.save(update_fields=["enabled"]) 

695 if request.headers.get("X-Requested-With") == "XMLHttpRequest": 

696 return JsonResponse({"enabled": rule.enabled, "pk": pk}) 

697 state = "enabled" if rule.enabled else "disabled" 

698 messages.success(request, f"Rule '{rule}' {state}.") 

699 referer = request.META.get("HTTP_REFERER", "") 

700 if referer and url_has_allowed_host_and_scheme( 

701 referer, allowed_hosts={request.get_host()}, require_https=request.is_secure() 

702 ): 

703 return redirect(referer) 

704 return redirect("plugins:netbox_interface_name_rules:interfacenamerule_list")