Coverage for /home/runner/work/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox_interface_name_rules/views.py: 94%

324 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 dataclasses 

4import logging 

5import re 

6 

7import yaml 

8from django.conf import settings 

9from django.contrib import messages 

10from django.core.exceptions import PermissionDenied 

11from django.http import JsonResponse 

12from django.shortcuts import redirect, render 

13from django.urls import reverse 

14from django.utils.http import url_has_allowed_host_and_scheme 

15from netbox.views import generic 

16from netbox.views.generic.base import BaseMultiObjectView 

17from utilities.views import register_model_view 

18 

19from .choices import BreakoutModeChoices 

20from .filters import InterfaceNameRuleFilterSet 

21from .forms import ( 

22 InterfaceNameRuleBulkEditForm, 

23 InterfaceNameRuleFilterForm, 

24 InterfaceNameRuleForm, 

25 InterfaceNameRuleImportForm, 

26 RuleTestForm, 

27) 

28from .models import InterfaceNameRule 

29from .tables import InterfaceNameRuleTable 

30 

31logger = logging.getLogger(__name__) 

32 

33try: 

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

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

36except (ValueError, TypeError): 

37 APPLY_BATCH_LIMIT = 50 

38 

39 

40@dataclasses.dataclass 

41class RulePreview: 

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

43 

44 module_type_is_regex: bool 

45 module_type_pattern: str 

46 module_type: object 

47 parent_module_type: object 

48 device_type: object 

49 platform: object 

50 name_template: str 

51 channel_count: int 

52 channel_start: int 

53 parent_name_template: str = "" 

54 breakout_mode: str = BreakoutModeChoices.FLAT 

55 

56 

57try: 

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

59 

60 class _YAMLOnlyExport(BulkExport): 

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

62 

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

64 

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

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

67except ImportError: 

68 _LIST_VIEW_ACTIONS = None 

69 

70 

71class InterfaceNameRuleListView(generic.ObjectListView): 

72 """List view for InterfaceNameRule.""" 

73 

74 queryset = InterfaceNameRule.objects.all() 

75 table = InterfaceNameRuleTable 

76 filterset = InterfaceNameRuleFilterSet 

77 filterset_form = InterfaceNameRuleFilterForm 

78 template_name = "netbox_interface_name_rules/interfacenamerule_list.html" 

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

80 actions = _LIST_VIEW_ACTIONS 

81 

82 def export_yaml(self): 

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

84 data = [] 

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

86 "module_type", "parent_module_type", "device_type", "platform" 

87 ): 

88 entry = {} 

89 for header, value in zip(InterfaceNameRule.csv_headers, rule.to_csv()): 

90 if (value != "" and value is not None) or header in {"name_template"}: 

91 entry[header] = value 

92 data.append(entry) 

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

94 

95 

96class InterfaceNameRuleCreateView(generic.ObjectEditView): 

97 """Create view for InterfaceNameRule.""" 

98 

99 queryset = InterfaceNameRule.objects.all() 

100 form = InterfaceNameRuleForm 

101 

102 

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

104class InterfaceNameRuleBulkImportView(generic.BulkImportView): 

105 """Bulk import view for InterfaceNameRule.""" 

106 

107 queryset = InterfaceNameRule.objects.all() 

108 model_form = InterfaceNameRuleImportForm 

109 

110 

111class InterfaceNameRuleView(generic.ObjectView): 

112 """Detail view for InterfaceNameRule.""" 

113 

114 queryset = InterfaceNameRule.objects.all() 

115 

116 

117class InterfaceNameRuleEditView(generic.ObjectEditView): 

118 """Edit view for InterfaceNameRule.""" 

119 

120 queryset = InterfaceNameRule.objects.all() 

121 form = InterfaceNameRuleForm 

122 

123 

124class InterfaceNameRuleDeleteView(generic.ObjectDeleteView): 

125 """Delete view for InterfaceNameRule.""" 

126 

127 queryset = InterfaceNameRule.objects.all() 

128 

129 

130class InterfaceNameRuleBulkEditView(generic.BulkEditView): 

131 """Bulk edit view for InterfaceNameRule.""" 

132 

133 queryset = InterfaceNameRule.objects.all() 

134 filterset = InterfaceNameRuleFilterSet 

135 table = InterfaceNameRuleTable 

136 form = InterfaceNameRuleBulkEditForm 

137 

138 

139class InterfaceNameRuleBulkDeleteView(generic.BulkDeleteView): 

140 """Bulk delete view for InterfaceNameRule.""" 

141 

142 queryset = InterfaceNameRule.objects.all() 

143 table = InterfaceNameRuleTable 

144 

145 

146class InterfaceNameRuleChangeLogView(generic.ObjectChangeLogView): 

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

148 

149 queryset = InterfaceNameRule.objects.all() 

150 

151 

152class InterfaceNameRuleDuplicateView(generic.ObjectView): 

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

154 

155 queryset = InterfaceNameRule.objects.all() 

156 

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

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

159 from utilities.querydict import prepare_cloned_fields 

160 

161 rule = self.get_object(**kwargs) 

162 params = prepare_cloned_fields(rule) 

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

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

165 

166 

167class RuleTestView(BaseMultiObjectView): 

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

169 

170 queryset = InterfaceNameRule.objects.all() 

171 template_name = "netbox_interface_name_rules/rule_test.html" 

172 

173 def get_required_permission(self): 

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

175 return "netbox_interface_name_rules.add_interfacenamerule" 

176 

177 def get(self, request): 

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

179 initial = {} 

180 loaded_rule = None 

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

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

183 if rule_id and not can_view: 

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

185 if rule_id and can_view: 

186 try: 

187 loaded_rule = ( 

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

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

190 .get(pk=int(rule_id)) 

191 ) 

192 initial = { 

193 "name_template": loaded_rule.name_template, 

194 "parent_name_template": loaded_rule.parent_name_template, 

195 "breakout_mode": loaded_rule.breakout_mode, 

196 "module_type_is_regex": loaded_rule.module_type_is_regex, 

197 "module_type": loaded_rule.module_type, 

198 "module_type_pattern": loaded_rule.module_type_pattern, 

199 "parent_module_type": loaded_rule.parent_module_type, 

200 "device_type": loaded_rule.device_type, 

201 "platform": loaded_rule.platform, 

202 "channel_count": loaded_rule.channel_count, 

203 "channel_start": loaded_rule.channel_start, 

204 } 

205 except (InterfaceNameRule.DoesNotExist, ValueError): 

206 pass 

207 return render(request, self.template_name, {"form": RuleTestForm(initial=initial), "loaded_rule": loaded_rule}) 

208 

209 def post(self, request): 

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

211 form = RuleTestForm(request.POST) 

212 preview_results = None 

213 db_preview = None 

214 db_total = 0 

215 error = None 

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

217 

218 if form.is_valid(): 

219 cd = form.cleaned_data 

220 if action == "save_rule": 

221 return self._handle_save_rule(request, cd) 

222 preview_results, error = self._evaluate_template_preview(cd) 

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

224 if db_error and not error: 

225 error = db_error 

226 

227 return render( 

228 request, 

229 self.template_name, 

230 { 

231 "form": form, 

232 "preview_results": preview_results, 

233 "db_preview": db_preview, 

234 "db_total": db_total, 

235 "error": error, 

236 }, 

237 ) 

238 

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

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

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

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

243 if module_type_is_regex: 

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

245 else: 

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

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

248 val = cd.get(field) 

249 if val: 

250 qs = qs.filter(**{field: val}) 

251 else: 

252 qs = qs.filter(**{f"{field}__isnull": True}) 

253 return qs.first() 

254 

255 def _handle_save_rule(self, request, cd): 

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

257 from urllib.parse import urlencode 

258 

259 name_template = cd["name_template"] 

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

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

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

263 module_type = cd.get("module_type") 

264 

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

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

267 # create form (potentially allowing duplicates). 

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

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

270 if existing: 

271 messages.info( 

272 request, 

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

274 ) 

275 return redirect( 

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

277 ) 

278 

279 params = { 

280 "name_template": name_template, 

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

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

283 "module_type_is_regex": "on" if module_type_is_regex else "", 

284 "channel_count": channel_count, 

285 "channel_start": channel_start, 

286 } 

287 if module_type_is_regex: 

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

289 elif module_type: 289 ↛ 291line 289 didn't jump to line 291 because the condition on line 289 was always true

290 params["module_type"] = module_type.pk 

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

292 val = cd.get(field) 

293 if val: 

294 params[field] = val.pk 

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

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

297 

298 def _evaluate_template_preview(self, cd): 

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

300 

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

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

303 """ 

304 from .engine import evaluate_name_template 

305 

306 name_template = cd["name_template"] 

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

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

309 variables = { 

310 "slot": cd.get("var_slot") or "1", 

311 "bay_position": cd.get("var_bay_position") or "1", 

312 "bay_position_num": cd.get("var_bay_position_num") or "1", 

313 "parent_bay_position": cd.get("var_parent_bay_position") or "1", 

314 "sfp_slot": cd.get("var_sfp_slot") or "1", 

315 "base": cd.get("var_base") or "Ethernet1", 

316 } 

317 

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

319 """Describe one previewed name.""" 

320 return {"source": variables["base"], "channel": channel, "result": result, "role": role} 

321 

322 try: 

323 if channel_count > 0: 

324 preview_results = [] 

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

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

327 parent_name = ( 

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

329 ) 

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

331 for ch in range(channel_count): 

332 channel = str(channel_start + ch) 

333 preview_results.append( 

334 row( 

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

336 ) 

337 ) 

338 else: 

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

340 return preview_results, None 

341 except Exception as exc: 

342 logger.exception("Template evaluation error: %s", exc) 

343 return None, type(exc).__name__ 

344 

345 def _fetch_db_preview(self, cd): 

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

347 from .engine import find_interfaces_for_rule 

348 

349 name_template = cd["name_template"] 

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

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

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

353 module_type = cd.get("module_type") 

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

355 

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

357 return None, 0, None 

358 

359 fake = RulePreview( 

360 module_type_is_regex=module_type_is_regex, 

361 module_type_pattern=module_type_pattern, 

362 module_type=module_type, 

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

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

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

366 name_template=name_template, 

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

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

369 channel_count=channel_count, 

370 channel_start=channel_start, 

371 ) 

372 try: 

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

374 return db_preview, db_total, None 

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

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

377 except Exception as exc: 

378 logger.exception("Unexpected error in find_interfaces_for_rule: %s", exc) 

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

380 

381 

382class RuleApplyListView(BaseMultiObjectView): 

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

384 

385 queryset = InterfaceNameRule.objects.all() 

386 template_name = "netbox_interface_name_rules/rule_apply.html" 

387 

388 def get_required_permission(self): 

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

390 return "netbox_interface_name_rules.view_interfacenamerule" 

391 

392 def get(self, request): 

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

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

395 "pk" 

396 ) 

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

398 

399 

400class RuleApplicableView(generic.ObjectView): 

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

402 

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

404 expensive full-scan queries blocking the initial render. 

405 """ 

406 

407 queryset = InterfaceNameRule.objects.all() 

408 

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

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

411 from .engine import has_applicable_interfaces 

412 

413 rule = self.get_object(**kwargs) 

414 try: 

415 applicable = has_applicable_interfaces(rule) 

416 except Exception as exc: 

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

418 return JsonResponse( 

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

420 status=500, 

421 ) 

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

423 

424 

425class RuleApplyDetailView(generic.ObjectView): 

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

427 

428 queryset = InterfaceNameRule.objects.all() 

429 template_name = "netbox_interface_name_rules/rule_apply_detail.html" 

430 additional_permissions = ["dcim.change_interface"] 

431 

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

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

434 from .engine import find_convertible_families, find_interfaces_for_rule, supports_channelization 

435 

436 rule = self.get_object(**kwargs) 

437 try: 

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

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

440 logger.exception("Failed to compute preview for rule %s: %s", rule, exc) 

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

442 preview, total_checked = [], 0 

443 except Exception as exc: 

444 logger.exception("Unexpected error computing preview for rule %s: %s", rule, exc) 

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

446 preview, total_checked = [], 0 

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

448 try: 

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

450 conversions, conversions_have_more = find_convertible_families(rule, limit=APPLY_BATCH_LIMIT) 

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

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

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

454 conversions, conversions_have_more = [], False 

455 except Exception as exc: 

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

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

458 conversions, conversions_have_more = [], False 

459 return render( 

460 request, 

461 self.template_name, 

462 { 

463 "rule": rule, 

464 "preview": preview, 

465 "conversions": conversions, 

466 "conversions_have_more": conversions_have_more, 

467 "total_checked": total_checked, 

468 "batch_limit": APPLY_BATCH_LIMIT, 

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

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

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

472 "families_supported": supports_channelization(), 

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

474 }, 

475 ) 

476 

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

478 """Enqueue *job_class* against *rule* and report the outcome to the operator.""" 

479 try: 

480 job = job_class.enqueue( 

481 # instance is intentionally omitted: InterfaceNameRule does not 

482 # inherit JobsMixin, so passing instance= would fail full_clean(). 

483 # The job is still named and findable in Core → Jobs. 

484 name=name, 

485 user=request.user, 

486 rule_id=rule.pk, 

487 ) 

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

489 except Exception as e: 

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

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

492 

493 def _convert(self, request, rule): 

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

495 from .engine import convert_flat_families 

496 

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

498 if not convert_ids: 

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

500 return 

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

502 if len(convert_ids) > APPLY_BATCH_LIMIT: 

503 messages.warning( 

504 request, 

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

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

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

508 ) 

509 return 

510 conflicts = [] 

511 try: 

512 count = convert_flat_families(rule, convert_ids, conflicts=conflicts) 

513 except Exception as e: 

514 logger.exception("Failed to convert families for rule %s: %s", rule, e) 

515 messages.error(request, f"Failed to convert families: {type(e).__name__}") 

516 return 

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

518 if conflicts: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

519 messages.warning(request, f"{len(conflicts)} family(ies) skipped — the plugin log names each one.") 

520 

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

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

523 from .engine import apply_rule_to_existing 

524 

525 rule = self.get_object(**kwargs) 

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

527 

528 if action == "convert": 

529 self._convert(request, rule) 

530 elif action == "convert_background": 

531 from .jobs import ConvertFlatFamiliesJob 

532 

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

534 elif action == "background": 

535 from .jobs import ApplyRuleJob 

536 

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

538 else: 

539 try: 

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

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

542 if not interface_ids: 

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

544 else: 

545 conflicts = [] 

546 count = apply_rule_to_existing( 

547 rule, limit=APPLY_BATCH_LIMIT, interface_ids=interface_ids, conflicts=conflicts 

548 ) 

549 messages.success(request, f"Applied rule: {count} interface(s) renamed.") 

550 if conflicts: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true

551 messages.warning( 

552 request, 

553 f"{len(conflicts)} interface(s) skipped — the plugin log names each one.", 

554 ) 

555 except Exception as e: 

556 logger.exception("Failed to apply rule %s: %s", rule, e) 

557 messages.error(request, f"Failed to apply rule {rule}: {type(e).__name__}") 

558 

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

560 

561 

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

563class RuleToggleView(generic.ObjectView): 

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

565 

566 queryset = InterfaceNameRule.objects.all() 

567 

568 def post(self, request, pk): 

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

570 rule = self.get_object(pk=pk) 

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

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

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

574 raise PermissionDenied 

575 rule.enabled = not rule.enabled 

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

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

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

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

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

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

582 if referer and url_has_allowed_host_and_scheme( 

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

584 ): 

585 return redirect(referer) 

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