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
« 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.
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.
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"""
31import dataclasses
32import functools
33import logging
34import math
35import weakref
36from typing import TYPE_CHECKING, NamedTuple
38from django.db import transaction
39from netbox.context import current_request
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
47if TYPE_CHECKING:
48 from .engine import ModuleNaming
50logger = logging.getLogger("netbox_interface_name_rules")
53@dataclasses.dataclass(frozen=True)
54class ModuleState:
55 """The module values a rename trigger compares."""
57 module_type_id: int
58 module_bay_id: int
59 device_id: int
61 def placement(self):
62 """Return the bay and the device that hold the module."""
63 return self.module_bay_id, self.device_id
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
70@dataclasses.dataclass(frozen=True)
71class DeviceState:
72 """The device values a rename trigger compares."""
74 virtual_chassis_id: int | None
75 vc_position: int | None
78@dataclasses.dataclass(frozen=True)
79class BayState:
80 """The module bay values a rename trigger compares, and the module installed in the bay.
82 ``naming_values`` are the bay's ``bay_naming_values``. ``module_pk`` and ``module_state`` are None
83 for an empty bay.
84 """
86 naming_values: tuple[str, str]
87 module_pk: int | None
88 module_state: ModuleState | None
91def _bay_values(bay):
92 """Return the ``bay_naming_values`` of *bay*."""
93 return bay_naming_values(bay.position, bay.name)
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)})
101@dataclasses.dataclass(eq=False)
102class _Trigger:
103 """A committed callback that records that its trigger's savepoint committed."""
105 kept: bool = dataclasses.field(default=False, init=False)
106 author: object = dataclasses.field(default=None, init=False)
108 def __call__(self):
109 """Mark the trigger as committed: Django drops this callback when its savepoint rolls back."""
110 self.kept = True
113@dataclasses.dataclass(eq=False)
114class ModuleTrigger(_Trigger):
115 """A save after which the names of one module may be wrong.
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 """
124 pk: int
125 baseline: ModuleState
126 installed: bool = False
127 naming: tuple = ()
128 moved: bool = False
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)
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."""
140 pk: int
141 baseline: DeviceState
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."""
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
154@dataclasses.dataclass(eq=False)
155class PlanRunner:
156 """A committed callback of a reapply plan; only the newest runner of the plan runs it."""
158 plan: ReapplyPlan
160 def __call__(self):
161 """Reapply the rules for the triggers whose savepoints committed, when this is the newest runner.
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])
172def reapply(triggers):
173 """Reapply the rules once for what *triggers*, in save order, ask for.
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)
192@dataclasses.dataclass
193class _ModuleRoot:
194 """What the triggers of one module ask for.
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 """
200 baseline: ModuleState
201 installed: bool
202 author: object
203 start: int
204 members: dict = dataclasses.field(default_factory=dict)
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
219def _earliest_naming(module_triggers, roots, device_triggers):
220 """Return the first ``ModuleNaming`` that the ``(place, trigger)`` *module_triggers* read for each module.
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
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
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.
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)
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
265def _journal_owner(pk, covering, roots):
266 """Return the module whose journal entry reports module *pk*.
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 )
281def _reapply_from_naming(module, entry, covering, changed):
282 """Return whether *module* reapplies from its naming *entry*; the rule comparison reads the rule cache.
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)
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.
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
312def _naming_points(module, root, reads, device_triggers):
313 """Return each point in the transaction at which NetBox gave *module*'s templates raw names.
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
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)
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.
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, ()))
359class _NamingRead(NamedTuple):
360 """A trigger's read of one module's naming, at the *place* of the trigger in save order."""
362 place: int
363 naming: "ModuleNaming"
364 moved: bool
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
376def _reapply_module(module, decide, outcomes):
377 """Reapply *module* with the options that *decide* returns, and add its outcome facts to *outcomes*.
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
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
403def _reapply_modules(module_triggers, device_triggers):
404 """Reapply each module that the ``(place, trigger)`` *module_triggers* reach once, and report each owner once.
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
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)
464def _reapply_device(trigger, attempted_pks):
465 """Reapply the module and device-interface rules of the device of its first *trigger*, once.
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
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
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
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)
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}")
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})"
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
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)
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
560def _module_trigger(module, created, previous):
561 """Return the trigger a module save is, or None when the save is not a rename trigger.
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())
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)
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)
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)
601def _needs_subtree_naming(state, current):
602 """Return whether a module save from *state* to *current* needs the naming of the module's subtree.
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))
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
619 return state, read_subtree_naming(module.pk)
622def _read_device(device):
623 """Return the device's previous state."""
624 return _read_state(type(device), DeviceState, device.pk)
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
652 return state, read_subtree_naming(module_pk)
655_TRIGGERS = {
656 "dcim.Module": (_read_module, _module_trigger),
657 "dcim.ModuleBay": (_read_bay, _bay_trigger),
658 "dcim.Device": (_read_device, _device_trigger),
659}
661# Keyed by id(): model equality follows the primary key, so two instances of one row would collide.
662_previous_states = {}
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}.")
672def before_save(sender, instance, using):
673 """Read the previous state of *instance*, saved through *using*, and hold it for its post_save.
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)
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)
690 _previous_states[key] = (weakref.ref(instance, forget), previous)
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.
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()