Coverage for dcim/signals.py: 28%
195 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
3from django.db import transaction
4from django.db.models import Q
5from django.db.models.signals import post_delete, post_save, pre_save
6from django.dispatch import receiver
8from dcim.choices import CableEndChoices, LinkStatusChoices
9from netbox.search.backends import search_backend
10from utilities.querysets import chunked_update
11from virtualization.models import VMInterface
13from .models import (
14 Cable,
15 CablePath,
16 CableTermination,
17 Device,
18 Interface,
19 Location,
20 PathEndpoint,
21 PortMapping,
22 PowerPanel,
23 Rack,
24 VirtualChassis,
25)
26from .models.cables import trace_paths
27from .search import DeviceIndex
28from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
30# The scope-relevant fields stashed before each model's save by cache_presave_scope_fields(),
31# so that the post_save handlers can tell whether the save actually changed any of them and
32# skip their work when it did not. Only the models whose cascades are still carried out in
33# Python are listed: the denormalized columns on device components, cable terminations, and
34# the CachedScopeMixin models are maintained by database triggers (see the
35# 'denormalization_triggers' migrations), which need no such stash.
36STASHED_SCOPE_FIELDS = {
37 Location: ('site_id',),
38 Rack: ('site_id', 'location_id'),
39}
42#
43# Location/rack/device assignment
44#
46def cache_presave_scope_fields(instance, raw=False, using=None, **kwargs):
47 """
48 Stash the scope-relevant field values currently in the database so that the post_save
49 handlers below can determine whether this save actually changed any of them. The read
50 locks the row, so overlapping saves of the same object serialize here and the
51 comparison always runs against the final committed state.
53 No stash is taken for a raw save, for a new instance, or outside a transaction: in
54 autocommit, this read and the subsequent UPDATE would run in separate transactions, so
55 the comparison could race a concurrent save. In each of those cases any stash left by a
56 previous save of the same instance is cleared, as it no longer reflects the current
57 database state. The post_save handlers treat a missing stash as "the values may have
58 changed" and rebuild or repair unconditionally — except on a raw save, which they skip
59 before consulting the stash at all, making the clearing there purely defensive.
60 """
61 if raw or instance.pk is None or not transaction.get_connection(using).in_atomic_block:
62 # Clear any stash left by a previous save of this instance.
63 instance._presave_scope_fields = None
64 return
65 fields = STASHED_SCOPE_FIELDS[instance.__class__]
66 instance._presave_scope_fields = (
67 instance.__class__.objects.using(using)
68 .filter(pk=instance.pk)
69 .order_by() # Clear default ordering to avoid JOINs
70 .select_for_update(no_key=True) # no_key: Avoid blocking foreign key inserts that reference this object
71 .values(*fields)
72 .first()
73 )
76for _model in STASHED_SCOPE_FIELDS:
77 pre_save.connect(cache_presave_scope_fields, sender=_model)
80# update_fields may name a foreign key by either its name ('site') or its attname
81# ('site_id') — Django accepts both — so deciding whether a save wrote a stashed field has
82# to test both forms. Derived from each model's own meta rather than written out, so the two
83# spellings cannot disagree.
84STASHED_FIELD_ALIASES = {
85 model: {
86 field.attname: frozenset((field.attname, field.name))
87 for field in model._meta.concrete_fields
88 if field.attname in fields
89 }
90 for model, fields in STASHED_SCOPE_FIELDS.items()
91}
94def _unwritten_scope_fields(instance, update_fields):
95 """
96 Return the scope-relevant fields listed for the instance's model which this save did
97 not write.
98 """
99 if update_fields is None:
100 return frozenset()
101 aliases = STASHED_FIELD_ALIASES[instance.__class__]
102 return frozenset(field for field, names in aliases.items() if names.isdisjoint(update_fields))
105def _scope_fields_unchanged(instance, update_fields=None):
106 """
107 Return True when the values stashed immediately before this save show that it changed
108 none of the scope-relevant fields listed for the instance's model, meaning the caller's
109 propagation or rebuild can be skipped in its entirety.
110 """
111 prev = getattr(instance, '_presave_scope_fields', None)
112 if prev is None:
113 return False
114 unwritten = _unwritten_scope_fields(instance, update_fields)
115 return all(value == getattr(instance, field) for field, value in prev.items() if field not in unwritten)
118def _scope_values(instance, update_fields, using):
119 """
120 Return the values the scope-relevant fields hold in the database once this save has
121 been applied, keyed by field name, for the propagation handlers to push down.
123 Must be called inside the transaction the propagation runs in: the fallback read below
124 locks the row for the remainder of it, so that no concurrent write can move the object
125 out from under the values being propagated.
127 Returns None when the row cannot be read at all, leaving the caller nothing to
128 propagate.
129 """
130 values = {field: getattr(instance, field) for field in STASHED_SCOPE_FIELDS[instance.__class__]}
131 unwritten = _unwritten_scope_fields(instance, update_fields)
132 if not unwritten:
133 return values
134 stashed = getattr(instance, '_presave_scope_fields', None)
135 if stashed is None:
136 stashed = (
137 instance.__class__.objects.using(using)
138 .filter(pk=instance.pk)
139 # Cleared for the same reason as in cache_presave_scope_fields().
140 .order_by()
141 .select_for_update(no_key=True)
142 .values(*unwritten)
143 .first()
144 )
145 # No row to read: it was deleted after this save committed, or was never inserted
146 # (an instance with a pre-assigned primary key).
147 if stashed is None:
148 return None
149 values.update({field: stashed[field] for field in unwritten})
150 return values
153@receiver(post_save, sender=Location)
154def handle_location_site_change(instance, created, raw=False, using=None, update_fields=None, **kwargs):
155 """
156 Cascade a Location's Site assignment down to the Racks, Devices, and PowerPanels it
157 contains (and to descendant Locations). All updates are queryset update() calls, which
158 fire no signals and generate no change records for the affected objects; the
159 denormalized columns on device components and cable terminations are refreshed by the
160 database triggers those updates fire in turn.
162 Each query is pinned to the connection the Location was saved on: on an installation
163 with database routers configured, letting the router pick the alias would both write to
164 a different database than the one being saved and leave the row locks below outside the
165 transaction opened here. For the same reason the new Site is assigned by ID: reading
166 instance.site would fetch the related object over a router-selected connection whenever
167 the save left it uncached (a rename, say).
169 When the values read from the database immediately before this save show that the Site
170 assignment is unchanged, the propagation is skipped: every value written below is
171 derived from it, so there is nothing for the descendants to pick up. A raw save is
172 skipped outright.
173 """
174 if created or raw:
175 return
177 # Skip the propagation when this save left the Site assignment untouched.
178 if _scope_fields_unchanged(instance, update_fields):
179 return
181 with transaction.atomic(using=using, savepoint=False):
182 scope = _scope_values(instance, update_fields, using)
183 if scope is None:
184 return
185 site_id = scope['site_id']
186 chunked_update(instance.get_descendants().using(using), site_id=site_id)
187 # Materialized once so every statement below sees the same membership, even if a
188 # concurrent commit renumbers the tree mid-handler.
189 locations = list(instance.get_descendants(include_self=True).using(using).values_list('pk', flat=True))
190 chunked_update(Rack.objects.using(using).filter(location__in=locations), site_id=site_id)
191 chunked_update(Device.objects.using(using).filter(location__in=locations), site_id=site_id)
192 chunked_update(PowerPanel.objects.using(using).filter(location__in=locations), site_id=site_id)
195@receiver(post_save, sender=Rack)
196def handle_rack_site_change(instance, created, raw=False, using=None, update_fields=None, **kwargs):
197 """
198 Cascade a Rack's Site/Location assignment down to the Devices it contains; the
199 denormalized columns on those Devices' components and cable terminations are refreshed
200 by the database triggers the update fires in turn. Queries are pinned to the connection
201 the Rack was saved on, and the new values are assigned by ID so that no related object
202 is fetched over a router-selected connection.
204 A save which changed neither assignment propagates nothing and is skipped, as does a
205 raw save.
206 """
207 if created or raw:
208 return
210 # Skip the propagation when this save left the Site and Location assignments untouched.
211 if _scope_fields_unchanged(instance, update_fields):
212 return
214 with transaction.atomic(using=using, savepoint=False):
215 scope = _scope_values(instance, update_fields, using)
216 if scope is None:
217 return
218 chunked_update(
219 Device.objects.using(using).filter(rack=instance),
220 site_id=scope['site_id'],
221 location_id=scope['location_id'],
222 )
225#
226# Virtual chassis
227#
229@receiver(post_save, sender=VirtualChassis)
230def assign_virtualchassis_master(instance, created, **kwargs):
231 """
232 When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
233 """
234 if created and instance.master: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 master = Device.objects.get(pk=instance.master.pk)
236 master.virtual_chassis = instance
237 master.vc_position = 1
238 master.save()
241@receiver(post_save, sender=VirtualChassis)
242def update_virtualchassis_member_search_cache(instance, created, raw=False, update_fields=None, **kwargs):
243 """
244 Refresh the search cache for member Devices when a VirtualChassis is renamed. DeviceIndex caches
245 virtual_chassis as its string value, so a rename would otherwise leave stale CachedValue entries.
246 """
247 if raw or created:
248 return
249 # The VC name is the only VC attribute cached on member Devices; skip saves that can't change it.
250 if update_fields is not None and 'name' not in update_fields: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 return
252 search_backend.cache(
253 Device.objects.filter(virtual_chassis=instance).select_related('virtual_chassis'),
254 indexer=DeviceIndex,
255 remove_existing=True
256 )
259#
260# Cables
261#
263@receiver(trace_paths, sender=Cable)
264def update_connected_endpoints(instance, created, raw=False, **kwargs):
265 """
266 When a Cable is saved with new terminations, retrace any affected cable paths.
267 """
268 logger = logging.getLogger('netbox.dcim.cable')
269 if raw:
270 logger.debug(f"Skipping endpoint updates for imported cable {instance}")
271 return
273 # Update cable paths if new terminations have been set
274 if instance._terminations_modified:
275 a_terminations = []
276 b_terminations = []
277 # Note: instance.terminations.all() is not safe to use here as it might be stale
278 for t in CableTermination.objects.filter(cable=instance):
279 if t.cable_end == CableEndChoices.SIDE_A:
280 a_terminations.append(t.termination)
281 else:
282 b_terminations.append(t.termination)
283 for nodes in [a_terminations, b_terminations]:
284 # Examine type of first termination to determine object type (all must be the same)
285 if not nodes:
286 continue
287 if isinstance(nodes[0], PathEndpoint):
288 create_cablepaths(nodes)
289 else:
290 rebuild_paths(nodes)
292 # Update status of CablePaths if Cable status has been changed
293 elif instance.status != instance._orig_status:
294 if instance.status != LinkStatusChoices.STATUS_CONNECTED:
295 chunked_update(CablePath.objects.filter(_nodes__contains=instance), is_active=False)
296 else:
297 rebuild_paths([instance])
300@receiver(post_delete, sender=Cable)
301def retrace_cable_paths(instance, **kwargs):
302 """
303 When a Cable is deleted, check for and update its connected endpoints
304 """
305 for cablepath in CablePath.objects.filter(_nodes__contains=instance):
306 cablepath.retrace()
309@receiver((post_delete, post_save), sender=PortMapping)
310def update_passthrough_port_paths(instance, **kwargs):
311 """
312 When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
313 """
314 for cablepath in CablePath.objects.filter(
315 Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
316 ):
317 cablepath.retrace()
320@receiver(post_delete, sender=CableTermination)
321def nullify_connected_endpoints(instance, using, **kwargs):
322 """
323 Disassociate the Cable from the termination object, and retrace any affected CablePaths.
324 """
325 model = instance.termination_type.model_class()
327 # Deleting a Cable deletes its terminations in bulk, bypassing CableTermination.delete() and the
328 # change-logged clear it performs on the terminating object; do the same here so the disconnect is
329 # recorded. `cable` is a SET_NULL FK which the deletion collector has already nulled by now, so the
330 # pre-delete values are restored before snapshotting, or the record would show no change. They are
331 # restored field by field rather than via set_cable_termination(), whose Interface override would
332 # propagate the cable back onto the channel subinterfaces we are about to clear.
333 termination = None
334 if Cable._is_being_deleted(instance.cable_id):
335 # The change record serializes the object twice (before and after), and ComponentModel.save()
336 # re-caches its denormalized references off the parent device: fetch both up front so neither
337 # costs a round trip per terminating object. Both the read and the save below use the alias the
338 # deletion ran on, so routing can't return a stale row or miss the object entirely.
339 queryset = model.objects.using(using).filter(pk=instance.termination_id).prefetch_related('tags')
340 if hasattr(model, 'device'):
341 queryset = queryset.select_related('device__site', 'device__location', 'device__rack')
342 termination = queryset.first()
344 if termination is not None:
345 termination.cable_id = instance.cable_id
346 termination.cable_end = instance.cable_end
347 termination.cable_connector = instance.connector
348 termination.cable_positions = instance.positions
349 termination.snapshot()
350 # clear_cable_termination() also clears the mirrored attributes on any channel subinterfaces
351 termination.clear_cable_termination(instance)
352 update_fields = ['cable', 'cable_end', 'cable_connector', 'cable_positions', 'last_updated']
354 # retrace_cable_paths() tears down the originating path once the Cable itself is deleted, clearing
355 # _path outside the changelog. Clear it here so the recorded state doesn't outlive the path.
356 if isinstance(termination, PathEndpoint):
357 termination._path = None
358 update_fields.append('_path')
360 # A narrow write: this row was read mid-cascade, and its full save() would pull in unrelated work
361 termination.save(using=using, update_fields=update_fields)
362 else:
363 # Already recorded by CableTermination.delete(), or the terminating object is going away too.
364 model.objects.filter(pk=instance.termination_id).update(
365 cable=None,
366 cable_end=None,
367 cable_connector=None,
368 cable_positions=None,
369 )
371 # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto
372 # its channel subinterfaces. This must happen before the retrace below so that each channel's (now dead)
373 # path is torn down rather than rebuilt from a stale cable reference. These writes are deliberately not
374 # change-logged, matching propagate_channel_cables(), which doesn't log them when it sets them either.
375 # A subinterface saved through a change-logged path while connected does record its mirrored cable
376 # fields, so its disconnect goes unlogged here; that gap predates this receiver and is untouched by it.
377 if model is Interface:
378 Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update(
379 cable=None, cable_end='', cable_connector=None, cable_positions=None
380 )
382 # If the parent Cable is being deleted in this same operation, skip the
383 # per-termination retrace; retrace_cable_paths() will retrace each affected
384 # path once after the Cable is deleted.
385 if Cable._is_being_deleted(instance.cable_id):
386 return
388 for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
389 # Remove the deleted CableTermination if it's one of the path's originating nodes
390 if instance.termination in cablepath.origins:
391 cablepath.origins.remove(instance.termination)
392 # Clear _path on the removed origin to prevent stale connection display
393 model.objects.filter(pk=instance.termination_id, _path=cablepath.pk).update(_path=None)
394 cablepath.retrace()
397# Fields this receiver reacts to. A save() whose update_fields is disjoint from this set (e.g. a plain rename)
398# cannot have touched channelization or cabling, so there's nothing for this receiver to do.
399_CHANNELIZATION_RELEVANT_FIELDS = frozenset({'channels', 'channel_id', 'parent', 'parent_id', 'cable', 'cable_id'})
402@receiver(post_save, sender=Interface)
403def update_channelized_cable_paths(instance, created, raw=False, update_fields=None, **kwargs):
404 """
405 Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel
406 subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an
407 already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.)
408 """
409 if raw: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true
410 return
411 if update_fields is not None and _CHANNELIZATION_RELEVANT_FIELDS.isdisjoint(update_fields): 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 return
414 parent_ids = set()
416 # A channel subinterface was added, moved between parents, or had its channel_id changed. Gated on an actual
417 # change (or creation) so a full re-save of an already-channelized child with neither field touched doesn't
418 # propagate cable state and rebuild the parent's paths for unrelated changes.
419 channelization_touched = (
420 created or instance.channel_id != instance._original_channel_id
421 or instance.parent_id != instance._original_parent_id
422 )
423 if channelization_touched and (instance.channel_id or instance._original_channel_id): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk)
426 # Channelization was toggled on this interface while it carries a cable
427 if instance.channels != instance._original_channels and instance.cable_id: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 parent_ids.add(instance.pk)
430 # Tracks whether anything below mutated instance's own row via a queryset/bulk operation (which bypasses
431 # this in-memory `instance`) rather than via save() -- see the refresh_from_db() call at the end.
432 own_row_mutated = False
434 # select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both
435 # propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a
436 # relation, so it needs no prefetching.)
437 parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable')
438 for parent in parents: 438 ↛ 439line 438 didn't jump to line 439 because the loop on line 438 never started
439 own_row_mutated = True
440 if parent.channels:
441 parent.propagate_channel_cables()
442 rebuild_cable_paths(parent.cable)
444 # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable
445 # attributes -- including when it was just detached from channelization entirely (channel_id and/or parent
446 # cleared), since it then drops out of the old parent's propagation queryset above and would otherwise keep
447 # its old cable cache indefinitely.
448 if (instance.channel_id or instance._original_channel_id) and instance.cable_id: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 parent = instance.parent if instance.channel_id else None
450 if not (parent and parent.channels and parent.cable_id):
451 Interface.objects.filter(pk=instance.pk).update(
452 cable=None, cable_end='', cable_connector=None, cable_positions=None
453 )
454 own_row_mutated = True
455 for cablepath in CablePath.objects.filter(_nodes__contains=instance):
456 if instance in cablepath.origins:
457 cablepath.delete()
459 # A channel child's own cable_id/cable_end/cable_connector/cable_positions/_path may have just been mutated
460 # at the DB level above -- mirrored from its parent (propagate_channel_cables(), which bulk_updates a
461 # separately-fetched copy of this same row), cleared (the queryset .update() above), or rewritten by
462 # rebuild_cable_paths()/CablePath.save()/.delete() (which set/clear _path on path origins via queryset
463 # .update(), also bypassing this in-memory `instance`) -- without touching this in-memory `instance`. A
464 # later full save() of this same instance by another caller in the same request (e.g.
465 # MACAddressShortcutMixin.update()'s second instance.save() for a combined mac_address change) would
466 # otherwise write those stale in-memory values back over what was just written. Gated on own_row_mutated so
467 # a full re-save of an already-consistent channel child (nothing channelization-related touched) doesn't
468 # pay for a refresh it doesn't need.
469 if own_row_mutated: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 instance.refresh_from_db(fields=['cable', 'cable_end', 'cable_connector', 'cable_positions', '_path'])
472 # Refresh the cached channelization state so that saving this same in-memory instance again compares against its
473 # current values rather than re-triggering propagation from a stale baseline.
474 instance._original_channels = instance.channels
475 instance._original_channel_id = instance.channel_id
476 instance._original_parent_id = instance.parent_id
479@receiver(post_delete, sender=Interface)
480def cleanup_channel_subinterface_paths(instance, **kwargs):
481 """
482 When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path
483 is torn down.
484 """
485 if instance.channel_id and instance.parent_id: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first()
487 if parent and parent.channels:
488 parent.propagate_channel_cables()
489 rebuild_cable_paths(parent.cable)
492@receiver(post_save, sender=Interface)
493@receiver(post_save, sender=VMInterface)
494def update_mac_address_interface(instance, created, raw, **kwargs):
495 """
496 When creating a new Interface or VMInterface, check whether a MACAddress has been designated as its primary. If so,
497 assign the MACAddress to the interface.
498 """
499 if created and not raw and instance.primary_mac_address: 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true
500 instance.primary_mac_address.assigned_object = instance
501 instance.primary_mac_address.save()