Coverage for netbox_interface_name_rules / rename_triggers.py: 99.77%

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

3"""Rename triggers: read the previous state, decide, and reapply the rules after commit. 

4 

5The receivers in ``signals.py`` pass every module, module bay and device save here, with the alias of 

6the save, which must be the write alias. ``before_save`` reads the previous state; ``after_save`` 

7compares it with the saved values and, when the save is a rename trigger, adds the trigger to the 

8reapply plan of the transaction on the connection of the save. Each trigger is a committed callback 

9of that connection, which runs only when its savepoint commits. Each trigger also appends a runner 

10of the plan that no savepoint rollback drops, and only the newest runner runs the plan, after every 

11trigger. So the plan acts on the triggers that committed, and it moves no committed callback. In a 

12netbox-branching branch the plan therefore runs after the branch transaction commits, when a new 

13module has its interfaces. The plan runs in a write scope on its alias. It reapplies each module and 

14each device at most once, from the earliest previous state of the transaction, so it acts on the net 

15change. A reapply that leaves an interface unrenamed, or fails, writes one journal entry. A reapply 

16that cannot read the committed rows is logged only. 

17 

18A save that moves a module, or that changes what the names in an occupied bay are built from, also 

19reads before the save what named the interfaces of that module and of every module nested in it. 

20After the save that naming is gone. A save that changes a module's type reads it too when a rule is 

21scoped to the old or the new type as a parent module type, because a nested module can then select 

22another rule. The plan recognises the earlier names of each module from the first naming read for it 

23in the transaction, at the virtual-chassis position that its device had then. It also recognises the 

24raw names that NetBox gave at each naming point of the transaction: an install and, on NetBox 4.7, 

25each move that carried the module; see ``_naming_points``. A module inside the subtree of another 

26moved, edited or retyped module reports in the journal entry of the outermost one. The plan reapplies 

27the modules of its module triggers before the devices, and a device reapply leaves out each module 

28that already reapplied. 

29""" 

30 

31import dataclasses 

32import functools 

33import logging 

34import math 

35import weakref 

36from typing import TYPE_CHECKING, NamedTuple 

37 

38from django.db import transaction 

39from netbox.context import current_request 

40 

41from .branching import replay_in_progress 

42from .naming import bay_naming_values, chassis_position 

43from .rename_outcomes import OutcomeKind, RenameOutcome, renamed_count 

44from .rule_selection import parent_type_scopes_a_rule 

45from .transactions import atomic_with_events, write_alias, write_scope 

46 

47if TYPE_CHECKING: 

48 from .engine import ModuleNaming 

49 

50logger = logging.getLogger("netbox_interface_name_rules") 

51 

52 

53@dataclasses.dataclass(frozen=True) 

54class ModuleState: 

55 """The module values a rename trigger compares.""" 

56 

57 module_type_id: int 

58 module_bay_id: int 

59 device_id: int 

60 

61 def placement(self): 

62 """Return the bay and the device that hold the module.""" 

63 return self.module_bay_id, self.device_id 

64 

65 def retyped_from(self, earlier): 

66 """Return whether the module type differs from the module type of the *earlier* state.""" 

67 return self.module_type_id != earlier.module_type_id 

68 

69 

70@dataclasses.dataclass(frozen=True) 

71class DeviceState: 

72 """The device values a rename trigger compares.""" 

73 

74 virtual_chassis_id: int | None 

75 vc_position: int | None 

76 

77 

78@dataclasses.dataclass(frozen=True) 

79class BayState: 

80 """The module bay values a rename trigger compares, and the module installed in the bay. 

81 

82 ``naming_values`` are the bay's ``bay_naming_values``. ``module_pk`` and ``module_state`` are None 

83 for an empty bay. 

84 """ 

85 

86 naming_values: tuple[str, str] 

87 module_pk: int | None 

88 module_state: ModuleState | None 

89 

90 

91def _bay_values(bay): 

92 """Return the ``bay_naming_values`` of *bay*.""" 

93 return bay_naming_values(bay.position, bay.name) 

94 

95 

96def _state_of(state_class, instance): 

97 """Return the *state_class* values that *instance* holds.""" 

98 return state_class(**{field.name: getattr(instance, field.name) for field in dataclasses.fields(state_class)}) 

99 

100 

101@dataclasses.dataclass(eq=False) 

102class _Trigger: 

103 """A committed callback that records that its trigger's savepoint committed.""" 

104 

105 kept: bool = dataclasses.field(default=False, init=False) 

106 author: object = dataclasses.field(default=None, init=False) 

107 

108 def __call__(self): 

109 """Mark the trigger as committed: Django drops this callback when its savepoint rolls back.""" 

110 self.kept = True 

111 

112 

113@dataclasses.dataclass(eq=False) 

114class ModuleTrigger(_Trigger): 

115 """A save after which the names of one module may be wrong. 

116 

117 ``baseline`` is the module's state before the save, or the state it was installed with when 

118 ``installed`` is set. ``naming`` is the ``ModuleNaming`` of the module and of each module nested in 

119 it, read by a save that moved the module, edited its bay or changed its type while a rule is scoped 

120 to the old or the new type as a parent module type; it is empty otherwise. ``moved`` is set when the 

121 save moved the module, and with it every module that ``naming`` holds. 

122 """ 

123 

124 pk: int 

125 baseline: ModuleState 

126 installed: bool = False 

127 naming: tuple = () 

128 moved: bool = False 

129 

130 @classmethod 

131 def after_install(cls, module): 

132 """Return the trigger of a module installed by this save.""" 

133 return cls(module.pk, _state_of(ModuleState, module), installed=True) 

134 

135 

136@dataclasses.dataclass(eq=False) 

137class DeviceTrigger(_Trigger): 

138 """A save after which the names on one device may be wrong; ``baseline`` is its state before the save.""" 

139 

140 pk: int 

141 baseline: DeviceState 

142 

143 

144@dataclasses.dataclass(eq=False) 

145class ReapplyPlan: 

146 """What the rename triggers of one transaction on *alias* ask for, in save order, and the newest runner.""" 

147 

148 alias: str 

149 triggers: list = dataclasses.field(default_factory=list) 

150 runner: "PlanRunner | None" = None 

151 started: bool = dataclasses.field(default=False, init=False) # captureOnCommitCallbacks keeps run callbacks 

152 

153 

154@dataclasses.dataclass(eq=False) 

155class PlanRunner: 

156 """A committed callback of a reapply plan; only the newest runner of the plan runs it.""" 

157 

158 plan: ReapplyPlan 

159 

160 def __call__(self): 

161 """Reapply the rules for the triggers whose savepoints committed, when this is the newest runner. 

162 

163 The reapply runs in a write scope on the plan's alias. The scope raises when the write alias differs. 

164 """ 

165 if self.plan.runner is not self or self.plan.started: 

166 return 

167 self.plan.started = True 

168 with write_scope(expected_alias=self.plan.alias): 

169 reapply([trigger for trigger in self.plan.triggers if trigger.kept]) 

170 

171 

172def reapply(triggers): 

173 """Reapply the rules once for what *triggers*, in save order, ask for. 

174 

175 The modules that the module triggers reach are reapplied first. Then each device is reapplied from 

176 its first trigger, in the order of those triggers, without the modules that the module reapply 

177 attempted. The place of a trigger is its index in *triggers*. 

178 """ 

179 module_triggers = [] 

180 device_triggers = {} 

181 for place, trigger in enumerate(triggers): 

182 if isinstance(trigger, DeviceTrigger): 

183 device_triggers.setdefault(trigger.pk, []).append((place, trigger)) 

184 else: 

185 module_triggers.append((place, trigger)) 

186 attempted_pks = _reapply_modules(module_triggers, device_triggers) if module_triggers else frozenset() 

187 for pairs in device_triggers.values(): 

188 _, first = pairs[0] 

189 _reapply_device(first, attempted_pks) 

190 

191 

192@dataclasses.dataclass 

193class _ModuleRoot: 

194 """What the triggers of one module ask for. 

195 

196 ``baseline`` and ``author`` come from its first trigger, and ``start`` is that trigger's place. 

197 ``members`` are the modules that its triggers read the naming of, in the order read. 

198 """ 

199 

200 baseline: ModuleState 

201 installed: bool 

202 author: object 

203 start: int 

204 members: dict = dataclasses.field(default_factory=dict) 

205 

206 

207def _module_roots(module_triggers): 

208 """Return the ``_ModuleRoot`` of each module that the ``(place, trigger)`` *module_triggers* name, in order.""" 

209 roots = {} 

210 for place, trigger in module_triggers: 

211 root = roots.get(trigger.pk) 

212 if root is None or trigger.installed: 

213 # An install starts over: an earlier trigger with this key was for a module deleted since. 

214 root = roots[trigger.pk] = _ModuleRoot(trigger.baseline, trigger.installed, trigger.author, place) 

215 root.members.update(dict.fromkeys(entry.module_pk for entry in trigger.naming)) 

216 return roots 

217 

218 

219def _earliest_naming(module_triggers, roots, device_triggers): 

220 """Return the first ``ModuleNaming`` that the ``(place, trigger)`` *module_triggers* read for each module. 

221 

222 The entry of a module installed in the transaction is ``raw_only``, and an entry read before that 

223 install was for a module deleted since. An entry takes the virtual-chassis position that its device 

224 had when the module's interfaces got their names: before the transaction, or at the install of a 

225 module installed in it. *device_triggers* hold the ``(place, trigger)`` pairs of each device. 

226 """ 

227 entries = {} 

228 for place, trigger in module_triggers: 

229 for entry in trigger.naming: 

230 if entry.module_pk not in entries: 

231 named = _named_entry(entry, place, roots.get(entry.module_pk), device_triggers) 

232 if named is not None: 

233 entries[entry.module_pk] = named 

234 return entries 

235 

236 

237def _named_entry(entry, place, root, device_triggers): 

238 """Return *entry*, read at *place*, at the naming point of its module, or None when it read a module deleted since.""" 

239 installed = root is not None and root.installed 

240 if installed and place < root.start: 

241 return None 

242 named_at = root.start if installed else -1 # -1 is before the first trigger of the transaction 

243 state = _state_when_named(device_triggers.get(entry.device_pk, ()), named_at, place) 

244 positioned = entry if state is None else entry.at_chassis_position(chassis_position(state)) 

245 return dataclasses.replace(positioned, raw_only=True) if installed else positioned 

246 

247 

248def _state_when_named(pairs, named_at, read_at): 

249 """Return the device state at place *named_at* when a device trigger before *read_at* changed it, else None. 

250 

251 *pairs* are the ``(place, trigger)`` pairs of the device, in save order. Nothing renamed the 

252 interfaces between *named_at* and *read_at*, so their names still come from that state. 

253 """ 

254 return next((trigger.baseline for place, trigger in pairs if named_at < place < read_at), None) 

255 

256 

257def _moved_or_bay_changed(root, entry, module): 

258 """Return whether *module* moved from the baseline of *root*, or its bay values differ from *entry*.""" 

259 if entry is None: 

260 return False 

261 moved = root is not None and _state_of(ModuleState, module).placement() != root.baseline.placement() 

262 return moved or _bay_values(module.module_bay) != entry.bay_values 

263 

264 

265def _journal_owner(pk, covering, roots): 

266 """Return the module whose journal entry reports module *pk*. 

267 

268 That is the first of *covering*, the moved, edited or retyped modules whose naming read *pk*, that 

269 no other of them read, or *pk* itself when *covering* is empty. 

270 """ 

271 return next( 

272 ( 

273 owner 

274 for owner in covering 

275 if not any(other != owner and owner in roots[other].members for other in covering) 

276 ), 

277 covering[0] if covering else pk, 

278 ) 

279 

280 

281def _reapply_from_naming(module, entry, covering, changed): 

282 """Return whether *module* reapplies from its naming *entry*; the rule comparison reads the rule cache. 

283 

284 It does when *entry* is ``raw_only``: the raw names were resolved at the install, so the naming 

285 recognises them. It does when the module, or a module of *covering*, is in *changed*. It does when a 

286 module of *covering* changed type and the module now selects another rule than *entry*. 

287 """ 

288 if entry.raw_only or module.pk in changed or not changed.isdisjoint(covering): 

289 return True 

290 return bool(covering) and entry.selects_another_rule(module) 

291 

292 

293def _reapply_options(module, root, entry, covering, changed, device_triggers, reads): 

294 """Return the ``module_rule_outcomes`` options that reapply *module*, or None when it needs no reapply. 

295 

296 *covering* are the moved, edited or retyped modules whose naming read the module, and *changed* are 

297 the modules that moved or had their bay values changed. *device_triggers* and *reads* give the 

298 naming points of the module; see ``_naming_points``. 

299 """ 

300 current = _state_of(ModuleState, module) 

301 if root is not None and current.retyped_from(root.baseline): 

302 # The module's earlier names came from another module type, so it reapplies as a type change. 

303 return {"force_reapply": True} 

304 naming_points = _naming_points(module, root, reads, device_triggers) 

305 if entry is not None and _reapply_from_naming(module, entry, covering, changed): 

306 return {"naming": entry, "naming_points": naming_points} 

307 if root is not None and (root.installed or current != root.baseline): 

308 return {"force_reapply": current != root.baseline, "naming_points": naming_points} 

309 return None 

310 

311 

312def _naming_points(module, root, reads, device_triggers): 

313 """Return each point in the transaction at which NetBox gave *module*'s templates raw names. 

314 

315 Its install names them, and on NetBox 4.7 so does each move that carried it. *reads* are the 

316 ``_NamingRead`` of each trigger that read the module's naming, in save order. A point 

317 names the bay chain that the next read holds, or the committed one after the last read, at the 

318 position that the device had at that point. A read before the install of the module was for a 

319 module deleted since. 

320 """ 

321 from .engine import NamingPoint 

322 from .family import supports_module_moves 

323 

324 start = root.start if root is not None and root.installed else None 

325 after = [read for read in reads if start is None or read.place > start] 

326 places = [] if start is None else [(start, False)] 

327 if supports_module_moves(): # pragma: no cover - requires a NetBox that renames moved components 

328 places += [(read.place, True) for read in after if read.moved] 

329 points = [] 

330 for named_at, move in places: 

331 chain = next((read.naming for read in after if read.place > named_at), None) 

332 device_pk = module.device_id if chain is None else chain.device_pk 

333 state = _state_when_named(device_triggers.get(device_pk, ()), named_at, math.inf) 

334 if state is not None: 

335 position = chassis_position(state) 

336 else: 

337 position = chassis_position(module.device) if chain is None else chain.vc_position 

338 points.append(NamingPoint(chain, position, move)) 

339 return tuple(points) 

340 

341 

342def _may_keep_a_name_of_a_move(reads, device_triggers): # pragma: no cover - requires a NetBox that moves components 

343 """Return whether a module can keep a raw name that a move gave it, also when it is back where it was. 

344 

345 NetBox renames a raw name back at a later move only when nothing that the name is built from changed 

346 since the earlier move: the position of a device that the module was on, and its bay chain, which 

347 a read that is not a move shows. *reads* are the module's ``_NamingRead``. 

348 """ 

349 moves = [read.place for read in reads if read.moved] 

350 if len(moves) < 2: 

351 return False 

352 first, last = moves[0], moves[-1] 

353 if any(first < read.place < last and not read.moved for read in reads): 

354 return True 

355 devices = {read.naming.device_pk for read in reads} 

356 return any(first < place < last for pk in devices for place, _ in device_triggers.get(pk, ())) 

357 

358 

359class _NamingRead(NamedTuple): 

360 """A trigger's read of one module's naming, at the *place* of the trigger in save order.""" 

361 

362 place: int 

363 naming: "ModuleNaming" 

364 moved: bool 

365 

366 

367def _reads(module_triggers): 

368 """Return the ``_NamingRead`` of each trigger that read a module's naming, by module.""" 

369 reads = {} 

370 for place, trigger in module_triggers: 

371 for naming in trigger.naming: 

372 reads.setdefault(naming.module_pk, []).append(_NamingRead(place, naming, trigger.moved)) 

373 return reads 

374 

375 

376def _reapply_module(module, decide, outcomes): 

377 """Reapply *module* with the options that *decide* returns, and add its outcome facts to *outcomes*. 

378 

379 *decide* returns None when the module needs no reapply. It can read the rules, so a failure to 

380 decide is one more fact, as a failure to reapply is. Return True when the module reapplied or 

381 failed to, and False when it needs no reapply. 

382 """ 

383 from .engine import module_rule_outcomes 

384 

385 renamed_before = renamed_count(outcomes) 

386 try: 

387 options = decide() 

388 if options is None: 

389 return False 

390 # extend() keeps the facts the generator yielded before a later family raised. 

391 outcomes.extend(module_rule_outcomes(module, module.module_bay, **options)) 

392 except Exception as error: 

393 logger.exception( 

394 "Failed to apply interface name rules for %s in %s", module.module_type, module.module_bay.name 

395 ) 

396 outcomes.append(_failure(error)) 

397 renamed = renamed_count(outcomes) - renamed_before 

398 if renamed: 

399 logger.info("Renamed %d interface(s) for %s in %s", renamed, module.module_type, module.module_bay.name) 

400 return True 

401 

402 

403def _reapply_modules(module_triggers, device_triggers): 

404 """Reapply each module that the ``(place, trigger)`` *module_triggers* reach once, and report each owner once. 

405 

406 A module reapplies as after a type change when its type changed. It reapplies from its earliest 

407 naming when it or a module whose naming read it moved or had its bay edited, when a module whose 

408 naming read it changed type and it now selects another rule, or when it was installed in the 

409 transaction and a naming was read for it. It reapplies as an install otherwise, which also 

410 recognises its raw names at the position of its install. Its outcomes go to the outermost of the 

411 moved, edited or retyped modules that read its naming, or to the module itself. *device_triggers* 

412 give each naming and each install the position of its device when the module was named. Return the 

413 primary key of each module that reapplied, or that failed to. 

414 """ 

415 from .engine import committed_modules, pinned_reapply 

416 from .family import supports_module_moves 

417 

418 roots = _module_roots(module_triggers) 

419 entries = _earliest_naming(module_triggers, roots, device_triggers) 

420 try: 

421 modules = committed_modules({*roots, *entries}) 

422 except Exception: 

423 # No committed row was read, so no object can carry a journal entry. 

424 logger.exception("Failed to read modules %s for their rename trigger reapply", sorted(roots)) 

425 return frozenset() 

426 present = [pk for pk in roots if pk in modules] 

427 reads = _reads(module_triggers) 

428 moves_rename = supports_module_moves() 

429 changed = { 

430 pk 

431 for pk in present 

432 if _moved_or_bay_changed(roots[pk], entries.get(pk), modules[pk]) 

433 or (moves_rename and _may_keep_a_name_of_a_move(reads.get(pk, ()), device_triggers)) 

434 } 

435 retyped = {pk for pk in present if _state_of(ModuleState, modules[pk]).retyped_from(roots[pk].baseline)} 

436 covers = [pk for pk in present if pk in changed or pk in retyped] 

437 walked = dict.fromkeys(pk for pk in roots for pk in (pk, *(roots[pk].members if pk in covers else ()))) 

438 outcomes_by_owner = {} 

439 attempted_pks = set() 

440 with pinned_reapply(modules.values()): 

441 for pk in walked: 

442 module = modules.get(pk) 

443 if module is None: 

444 continue 

445 covering = [other for other in covers if other != pk and pk in roots[other].members] 

446 decide = functools.partial( 

447 _reapply_options, 

448 module, 

449 roots.get(pk), 

450 entries.get(pk), 

451 covering, 

452 changed, 

453 device_triggers, 

454 reads.get(pk, ()), 

455 ) 

456 outcomes = outcomes_by_owner.setdefault(_journal_owner(pk, covering, roots), []) 

457 if _reapply_module(module, decide, outcomes): 

458 attempted_pks.add(pk) 

459 for owner, outcomes in outcomes_by_owner.items(): 

460 _report(modules[owner], outcomes, roots[owner].author) 

461 return frozenset(attempted_pks) 

462 

463 

464def _reapply_device(trigger, attempted_pks): 

465 """Reapply the module and device-interface rules of the device of its first *trigger*, once. 

466 

467 The modules in *attempted_pks* are left out: the module reapply reapplied them, reading the device 

468 after commit too, or reported that it failed to. 

469 """ 

470 from dcim.models import Device 

471 

472 pk, baseline, author = trigger.pk, trigger.baseline, trigger.author 

473 try: 

474 device = Device.objects.select_related("virtual_chassis").get(pk=pk) 

475 except Device.DoesNotExist: 

476 return 

477 except Exception: 

478 # No committed row was read, so no object can carry a journal entry. 

479 logger.exception("Failed to read device %s for its rename trigger reapply", pk) 

480 return 

481 current = _state_of(DeviceState, device) 

482 if current == baseline: 

483 return 

484 # Off a chassis or without a position, what the interfaces are called is the operator's decision. 

485 report_only = current.virtual_chassis_id is None or current.vc_position is None 

486 outcomes = [] 

487 try: 

488 from .engine import NamingPoint, device_module_rule_outcomes 

489 

490 # The module names on the device come from its position before its first trigger. 

491 before = (NamingPoint(None, chassis_position(baseline), move=False),) 

492 # extend() keeps the facts the generator yielded before a later module raised. 

493 outcomes.extend( 

494 device_module_rule_outcomes( 

495 device, report_only=report_only, excluded_pks=attempted_pks, naming_points=before 

496 ) 

497 ) 

498 except Exception as error: 

499 logger.exception("Failed to re-apply module rules for device %s after VC change", pk) 

500 outcomes.append(_failure(error)) 

501 try: 

502 from .engine import device_interface_rule_outcomes 

503 

504 outcomes.extend(device_interface_rule_outcomes(device, report_only=report_only)) 

505 except Exception as error: 

506 logger.exception("Failed to re-apply device interface rules for device %s after VC change", pk) 

507 outcomes.append(_failure(error)) 

508 total = renamed_count(outcomes) 

509 if total: 

510 logger.info("Re-renamed %d interface(s) for device %s after VC change", total, device) 

511 _report(device, outcomes, author) 

512 

513 

514def _failure(error): 

515 """Return the outcome fact of a reapply that *error* stopped.""" 

516 return RenameOutcome(OutcomeKind.FAILED, None, f"the reapply stopped with {type(error).__name__}: {error}") 

517 

518 

519def _journal_line(outcome): 

520 """Return the Markdown list item that names one outcome's interface and reason.""" 

521 if outcome.interface_name is None: 

522 return f"- {outcome.reason} ({outcome.kind})" 

523 subject = f"`{outcome.interface_name}`" 

524 if outcome.target_name not in (None, outcome.interface_name): 

525 subject += f" to `{outcome.target_name}`" 

526 return f"- {subject}: {outcome.reason} ({outcome.kind})" 

527 

528 

529def _report(target, outcomes, author): 

530 """Write one journal entry on *target* when an outcome is a skip or a failure. A failed write is logged.""" 

531 reported = [outcome for outcome in outcomes if outcome.kind != OutcomeKind.RENAMED] 

532 if not reported: 

533 return 

534 from extras.choices import JournalEntryKindChoices 

535 from extras.models import JournalEntry 

536 

537 failed = any(outcome.kind == OutcomeKind.FAILED for outcome in reported) 

538 kind = JournalEntryKindChoices.KIND_DANGER if failed else JournalEntryKindChoices.KIND_WARNING 

539 comments = "\n".join( 

540 ( 

541 "Interface Name Rules reapplied its rules after this change, with these results:", 

542 "", 

543 *(_journal_line(outcome) for outcome in reported), 

544 ) 

545 ) 

546 logger.warning("Rename trigger on %s: %d interface(s) skipped or failed; see its journal", target, len(reported)) 

547 try: 

548 with atomic_with_events(): 

549 JournalEntry.objects.create(assigned_object=target, created_by=author, kind=kind, comments=comments) 

550 except Exception: 

551 logger.exception("Failed to write the rename journal entry for %s", target) 

552 

553 

554def _request_user(): 

555 """Return the authenticated user of the current request, or None outside a request.""" 

556 user = getattr(current_request.get(), "user", None) 

557 return user if user is not None and user.is_authenticated else None 

558 

559 

560def _module_trigger(module, created, previous): 

561 """Return the trigger a module save is, or None when the save is not a rename trigger. 

562 

563 *previous* is ``(state, naming)`` from ``_read_module``, or None without a previous state. 

564 """ 

565 if created: 

566 return ModuleTrigger.after_install(module) 

567 state, naming = previous or (None, ()) 

568 if state is None: 

569 return None 

570 current = _state_of(ModuleState, module) 

571 if current == state: 

572 return None 

573 logger.debug("Module %s changed from %s to %s; adding it to the reapply plan", module.pk, state, current) 

574 return ModuleTrigger(module.pk, state, naming=naming, moved=current.placement() != state.placement()) 

575 

576 

577def _device_trigger(device, created, previous): 

578 """Return the trigger a device save is, or None when the save is not a rename trigger.""" 

579 if created or previous is None: 

580 return None 

581 if _state_of(DeviceState, device) == previous: 

582 return None 

583 return DeviceTrigger(device.pk, previous) 

584 

585 

586def _bay_trigger(bay, created, previous): 

587 """Return the trigger of the module in *bay* that a bay save is, or None when it is not a rename trigger.""" 

588 state, naming = previous or (None, ()) 

589 if created or state is None or state.module_pk is None or _bay_values(bay) == state.naming_values: 

590 return None 

591 logger.debug("Module bay %s changed from %s; adding module %s to the reapply plan", bay.pk, state, state.module_pk) 

592 return ModuleTrigger(state.module_pk, state.module_state, naming=naming) 

593 

594 

595def _read_state(model, state_class, pk): 

596 """Return the *state_class* values the database holds for row *pk*, or None without a row.""" 

597 row = model.objects.filter(pk=pk).values(*(field.name for field in dataclasses.fields(state_class))).first() 

598 return None if row is None else state_class(**row) 

599 

600 

601def _needs_subtree_naming(state, current): 

602 """Return whether a module save from *state* to *current* needs the naming of the module's subtree. 

603 

604 A move needs it. A type change needs it when a rule is scoped to the old or the new type as a parent 

605 module type: only then can a module nested in the module select another rule. 

606 """ 

607 if current.placement() != state.placement(): 

608 return True 

609 return current.retyped_from(state) and parent_type_scopes_a_rule((state.module_type_id, current.module_type_id)) 

610 

611 

612def _read_module(module): 

613 """Return ``(state, naming)``: the module's previous state, and the naming of its subtree if the save needs it.""" 

614 state = _read_state(type(module), ModuleState, module.pk) 

615 if state is None or not _needs_subtree_naming(state, _state_of(ModuleState, module)): 

616 return state, () 

617 from .engine import read_subtree_naming 

618 

619 return state, read_subtree_naming(module.pk) 

620 

621 

622def _read_device(device): 

623 """Return the device's previous state.""" 

624 return _read_state(type(device), DeviceState, device.pk) 

625 

626 

627def _read_bay(bay): 

628 """Return ``(state, naming)``: the bay's previous state, and its module's subtree naming if the save changes it.""" 

629 row = ( 

630 type(bay) 

631 .objects.filter(pk=bay.pk) 

632 .values("position", "name", "installed_module", "installed_module__module_type", "installed_module__device") 

633 .first() 

634 ) 

635 if row is None: 

636 return None 

637 module_pk = row["installed_module"] 

638 module_state = ( 

639 None 

640 if module_pk is None 

641 else ModuleState( 

642 module_type_id=row["installed_module__module_type"], 

643 module_bay_id=bay.pk, 

644 device_id=row["installed_module__device"], 

645 ) 

646 ) 

647 state = BayState(bay_naming_values(row["position"], row["name"]), module_pk, module_state) 

648 if module_pk is None or _bay_values(bay) == state.naming_values: 

649 return state, () 

650 from .engine import read_subtree_naming 

651 

652 return state, read_subtree_naming(module_pk) 

653 

654 

655_TRIGGERS = { 

656 "dcim.Module": (_read_module, _module_trigger), 

657 "dcim.ModuleBay": (_read_bay, _bay_trigger), 

658 "dcim.Device": (_read_device, _device_trigger), 

659} 

660 

661# Keyed by id(): model equality follows the primary key, so two instances of one row would collide. 

662_previous_states = {} 

663 

664 

665def _check_write_alias(using): 

666 """Raise when a save writes through *using*, which is not the alias that the plugin writes to.""" 

667 alias = write_alias() 

668 if using != alias: 

669 raise RuntimeError(f"A rename trigger save writes to {using!r}, but the write alias is {alias!r}.") 

670 

671 

672def before_save(sender, instance, using): 

673 """Read the previous state of *instance*, saved through *using*, and hold it for its post_save. 

674 

675 A read error propagates, and so does a save through another alias than the write alias. A save that 

676 netbox-branching replays returns before the write-alias check: a merge writes ``default`` while a branch is active. 

677 """ 

678 if replay_in_progress(): 

679 return 

680 _check_write_alias(using) 

681 read, _ = _TRIGGERS[sender._meta.label] 

682 previous = None if instance.pk is None else read(instance) 

683 key = id(instance) 

684 

685 def forget(reference): 

686 # A save that failed before post_save leaves its entry until the instance is collected. 

687 if _previous_states.get(key, (None,))[0] is reference: 687 ↛ exitline 687 didn't return from function 'forget' because the condition on line 687 was always true

688 _previous_states.pop(key, None) 

689 

690 _previous_states[key] = (weakref.ref(instance, forget), previous) 

691 

692 

693def after_save(sender, instance, created, using): 

694 """Add the save of *instance* through *using* to the reapply plan of that connection when it is a rename trigger. 

695 

696 A save that netbox-branching replays returns before the write-alias check, as in ``before_save``. 

697 """ 

698 if replay_in_progress(): 

699 return 

700 _check_write_alias(using) 

701 _, trigger_of = _TRIGGERS[sender._meta.label] 

702 # No entry: NetBox sent this post_save by hand, without a model save. 

703 _, previous = _previous_states.pop(id(instance), (None, None)) 

704 trigger = trigger_of(instance, created, previous) 

705 if trigger is None: 

706 return 

707 trigger.author = _request_user() 

708 connection = transaction.get_connection(using) 

709 entries = connection.run_on_commit if connection.in_atomic_block else () 

710 plan = next( 

711 (callback.plan for _, callback, _ in entries if isinstance(callback, PlanRunner) and not callback.plan.started), 

712 None, 

713 ) or ReapplyPlan(using) 

714 plan.triggers.append(trigger) 

715 # Django drops the callbacks of a rolled-back savepoint from run_on_commit, so a dropped trigger is not kept. 

716 transaction.on_commit(trigger, using=using) 

717 plan.runner = PlanRunner(plan) 

718 if connection.in_atomic_block: 

719 # Without a savepoint tag no rollback but the transaction's drops the runner; it runs after every trigger. 

720 connection.run_on_commit.append((set(), plan.runner, False)) 

721 else: 

722 plan.runner()