Coverage for extras/signals.py: 62%
181 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
1from django.contrib.contenttypes.models import ContentType
2from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
3from django.dispatch import receiver
5from core.events import *
6from core.signals import job_end, job_start
7from extras.choices import CustomFieldStatusChoices
8from extras.events import EventContext, process_event_rules
9from extras.models import EventRule, Notification, Subscription
10from netbox.config import get_config
11from netbox.models.features import has_feature
12from netbox.signals import post_clean
13from utilities.data import get_config_value_ci
14from utilities.exceptions import AbortRequest
16from .cache import (
17 invalidate_config_context_for_configcontext,
18 invalidate_config_context_for_objects,
19 invalidate_for_scope_delta,
20)
21from .constants import CC_FIELDS_BY_MODEL
22from .models import ConfigContext, CustomField, TaggedItem
23from .utils import run_validators
25#
26# Custom fields
27#
30def handle_cf_object_types_changed(instance, action, pk_set, reverse, **kwargs):
31 """
32 Handle the stored data of a CustomField as it is assigned to or unassigned from object types.
34 Only the forward direction is handled: every action below operates on the CustomField, whereas
35 the reverse of this relation (ContentType.custom_fields) reports the ContentType as the sender's
36 instance. Nothing in NetBox assigns object types that way.
38 Both unassignment actions are handled before the fact, so that remove_data() refusing the change
39 precedes the removal of the assignments themselves. Django wraps each of these operations in a
40 transaction, so the refusal would roll the removal back in any case -- but only where the caller
41 left that transaction to it.
42 """
43 if reverse or action not in ('pre_clear', 'post_add', 'pre_remove'):
44 return
46 if action == 'pre_clear':
47 # clear() unassigns every object type at once, and reports no pk_set, so the assignments
48 # have to be read while they still exist. (Note that set() diffs via remove()/add() by
49 # default, so it does not land here.)
50 instance.remove_data(instance.object_types.all())
51 return
53 object_types = ContentType.objects.filter(pk__in=pk_set)
55 if action == 'post_add':
56 # Populate the field's default value (if any) on the existing objects of the types just
57 # assigned.
58 instance.provision_data(object_types)
59 else:
60 # Remove the field's stored data from objects to which it no longer applies.
61 instance.remove_data(object_types)
64def handle_cf_renamed(instance, created, **kwargs):
65 """
66 Handle the renaming of custom field data on objects when a CustomField is renamed.
67 """
68 if not created and instance.name != instance._name:
69 instance.rename_object_data(old_name=instance._name, new_name=instance.name)
72def handle_cf_deleted(instance, **kwargs):
73 """
74 Handle the cleanup of old custom field data when a CustomField is deleted.
76 A field already marked for deletion is skipped: its data is too voluminous to purge inline, and
77 CustomFieldPurgeJob is removing it (see CustomField.delete()).
78 """
79 if instance.status != CustomFieldStatusChoices.STATUS_DELETING:
80 instance.remove_stale_data(instance.object_types.all())
83def handle_cf_cache_invalidation(action=None, **kwargs):
84 """
85 Discard the custom fields cached for the current request whenever one is created, modified,
86 deleted, or (un)assigned from an object type.
88 The cache spans the whole of a request -- and the whole of a script or job run, which share one
89 for their entire duration -- so without this a field created or changed partway through would be
90 served from what was read before it, to everything which followed.
92 A field's status is written via the queryset and so reaches none of these signals; the paths
93 which write it clear the cache themselves (see CustomFieldManager.clear_cache).
94 """
95 # m2m_changed fires either side of the change; clear once it has actually been applied.
96 if action is not None and not action.startswith('post_'):
97 return
99 CustomField.objects.clear_cache()
102post_save.connect(handle_cf_renamed, sender=CustomField)
103pre_delete.connect(handle_cf_deleted, sender=CustomField)
104m2m_changed.connect(handle_cf_object_types_changed, sender=CustomField.object_types.through)
106post_save.connect(handle_cf_cache_invalidation, sender=CustomField)
107post_delete.connect(handle_cf_cache_invalidation, sender=CustomField)
108m2m_changed.connect(handle_cf_cache_invalidation, sender=CustomField.object_types.through)
111#
112# Custom validation
113#
115@receiver(post_clean)
116def run_save_validators(sender, instance, **kwargs):
117 """
118 Run any custom validation rules for the model prior to calling save().
119 """
120 model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
121 validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
123 run_validators(instance, validators)
126#
127# Tags
128#
130@receiver(m2m_changed, sender=TaggedItem)
131def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
132 """
133 Validate that any Tags being assigned to the instance are not restricted to non-applicable object types.
134 """
135 if action != 'pre_add': 135 ↛ 137line 135 didn't jump to line 137 because the condition on line 135 was always true
136 return
137 ct = ContentType.objects.get_for_model(instance)
138 # Retrieve any applied Tags that are restricted to certain object types
139 for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
140 if ct not in tag.object_types.all():
141 raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
144#
145# Config context cache invalidation
146#
148@receiver(post_save, sender=ConfigContext)
149def invalidate_on_configcontext_save(sender, instance, using=None, **kwargs):
150 """
151 Whenever a ConfigContext's scalar fields change (e.g. `data`, `weight`, `is_active`),
152 invalidate the caches of all Devices/VMs currently in scope. M2M scope changes are handled
153 separately by invalidate_on_configcontext_m2m_change().
154 """
155 invalidate_config_context_for_configcontext(instance, using=using)
158@receiver(pre_delete, sender=ConfigContext)
159def invalidate_on_configcontext_delete(sender, instance, using=None, **kwargs):
160 """
161 Before a ConfigContext is deleted, invalidate the caches of all Devices/VMs currently in
162 scope. The scope is still readable here (pre_delete fires before the row and its M2M rows
163 are removed).
164 """
165 invalidate_config_context_for_configcontext(instance, using=using)
168def invalidate_on_configcontext_m2m_change(sender, instance, action, pk_set, scope_field, using=None, **kwargs):
169 """
170 Whenever a ConfigContext's scope M2M changes, invalidate the caches of all Devices/VMs that
171 were or now are in scope.
173 Strategy:
174 - For post_add: the current scope is broader than (or equal to) the previous scope. Devices
175 newly in scope are caught by invalidating the current affected set.
176 - For post_remove: the current scope is narrower. We must also invalidate devices that
177 matched only via the just-removed scope items.
178 - For post_clear: the scope is now empty (matches all). The current full affected set is the
179 broadest possible for this attribute; invalidating it suffices.
180 """
181 if action not in ('post_add', 'post_remove', 'post_clear'):
182 return
184 # Always invalidate based on the current (post-change) scope.
185 invalidate_config_context_for_configcontext(instance, using=using)
187 # For post_remove, also invalidate devices/VMs that matched via the removed scope items.
188 if action == 'post_remove' and pk_set:
189 invalidate_for_scope_delta(scope_field, pk_set, using=using)
192def _connect_configcontext_m2m_handlers():
193 """
194 Wire `invalidate_on_configcontext_m2m_change` to every ConfigContext scope M2M's through
195 model. The set of scope M2Ms is introspected from the model so new ones are picked up
196 automatically. The receiver is curried with `scope_field` to identify which attribute changed.
197 """
198 for m2m_field in ConfigContext._meta.many_to_many:
199 field_name = m2m_field.name
200 through = getattr(ConfigContext, field_name).through
202 def _handler(sender, instance, action, pk_set, using=None, _field=field_name, **kwargs):
203 invalidate_on_configcontext_m2m_change(
204 sender=sender,
205 instance=instance,
206 action=action,
207 pk_set=pk_set,
208 scope_field=_field,
209 using=using,
210 **kwargs,
211 )
213 m2m_changed.connect(_handler, sender=through, weak=False)
216_connect_configcontext_m2m_handlers()
219def _changed_fields(instance, fields):
220 """
221 Return True if any of `fields` differs between the prechange snapshot and the current state.
222 If no snapshot exists (e.g. object loaded fresh from DB and saved without a snapshot), assume
223 we cannot tell what changed and conservatively return True. The cost is one extra background
224 re-render per non-instrumented save; the cost of returning False would be stale caches.
225 """
226 snapshot = getattr(instance, '_prechange_snapshot', None)
227 if not snapshot: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 return True
229 for field in fields:
230 # Snapshot keys mirror Django's JSON serializer: FK ids are stored under the bare name
231 # (no `_id` suffix). Convert.
232 snap_key = field[:-3] if field.endswith('_id') else field
233 if snapshot.get(snap_key) != getattr(instance, field, None): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 return True
235 return False
238def _make_object_save_handler(model_label):
239 fields = CC_FIELDS_BY_MODEL[model_label]
241 def _handler(sender, instance, created, using=None, **kwargs):
242 # On creation, enqueue a render so the new object's cache is warmed promptly (there is no
243 # recurring sweep). On update, only invalidate when a scope-relevant field actually changed.
244 if created or _changed_fields(instance, fields):
245 invalidate_config_context_for_objects(model_label, [instance.pk], using=using)
247 return _handler
250def _connect_object_save_handlers():
251 from django.apps import apps as django_apps
253 for model_label in CC_FIELDS_BY_MODEL:
254 Model = django_apps.get_model(model_label)
255 post_save.connect(_make_object_save_handler(model_label), sender=Model, weak=False)
258_connect_object_save_handlers()
261@receiver(m2m_changed, sender=TaggedItem)
262def invalidate_on_device_vm_tag_change(sender, instance, action, using=None, **kwargs):
263 """
264 When tags are added or removed on a Device/VM, invalidate that object's cache.
265 """
266 if action not in ('post_add', 'post_remove', 'post_clear'):
267 return
268 from dcim.models import Device
269 from virtualization.models import VirtualMachine
271 if isinstance(instance, Device): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 invalidate_config_context_for_objects('dcim.device', [instance.pk], using=using)
273 elif isinstance(instance, VirtualMachine): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 invalidate_config_context_for_objects('virtualization.virtualmachine', [instance.pk], using=using)
277# Upstream object changes that affect ConfigContext matching even when the Device/VM itself is
278# untouched. Two patterns are handled:
279#
280# 1. Direct FK changes (Site.region, Cluster.type, Tenant.group, ...): invalidate the caches of
281# Devices/VMs that reference the changed object.
282# 2. Ltree reparents (Region.parent, SiteGroup.parent, ...): invalidate every Device/VM whose
283# attribute resolves into the changed node's subtree, because the ancestor list used by the
284# matching query has shifted.
287def _make_direct_upstream_handler(fields, device_lookup, vm_lookup):
288 def _handler(sender, instance, created, using=None, **kwargs):
289 if created or not _changed_fields(instance, fields): 289 ↛ 291line 289 didn't jump to line 291 because the condition on line 289 was always true
290 return
291 from dcim.models import Device
292 from virtualization.models import VirtualMachine
294 if device_lookup:
295 invalidate_config_context_for_objects(
296 'dcim.device',
297 Device.objects.using(using).filter(**{device_lookup: instance.pk}).values_list('pk', flat=True),
298 using=using,
299 )
300 if vm_lookup:
301 invalidate_config_context_for_objects(
302 'virtualization.virtualmachine',
303 VirtualMachine.objects.using(using).filter(**{vm_lookup: instance.pk}).values_list('pk', flat=True),
304 using=using,
305 )
307 return _handler
310def _make_reparent_handler(device_attr, vm_attr):
311 def _handler(sender, instance, created, using=None, **kwargs):
312 if created or not _changed_fields(instance, ('parent_id',)): 312 ↛ 314line 312 didn't jump to line 314 because the condition on line 312 was always true
313 return
314 from dcim.models import Device
315 from virtualization.models import VirtualMachine
317 # The ltree triggers rewrite `path` server-side during the UPDATE, but LtreeModel.save()
318 # only refreshes the in-memory value AFTER post_save fires — so `instance.path` is still
319 # the pre-move value here. Re-read the node's current path from the DB to enumerate its
320 # (post-move) subtree. The set of node PKs is invariant under a move; only their paths
321 # shift, so this matches the same Devices/VMs regardless of timing.
322 model = type(instance)
323 node_path = model.objects.using(using).filter(pk=instance.pk).values_list('path', flat=True).first()
324 if node_path is None:
325 return
326 subtree_pks = list(
327 model.objects.using(using).filter(path__descendant_or_equal=node_path).values_list('pk', flat=True)
328 )
330 if device_attr:
331 invalidate_config_context_for_objects(
332 'dcim.device',
333 Device.objects.using(using).filter(**{device_attr: subtree_pks}).values_list('pk', flat=True),
334 using=using,
335 )
336 if vm_attr:
337 invalidate_config_context_for_objects(
338 'virtualization.virtualmachine',
339 VirtualMachine.objects.using(using).filter(**{vm_attr: subtree_pks}).values_list('pk', flat=True),
340 using=using,
341 )
343 return _handler
346def _connect_upstream_handlers():
347 from django.apps import apps as django_apps
349 # (app, model, fields_to_watch, device_lookup, vm_lookup)
350 direct_triggers = (
351 ('dcim', 'Site', ('region_id', 'group_id'), 'site_id', 'site_id'),
352 ('dcim', 'Location', ('site_id',), 'location_id', None),
353 # Cluster's effective site is the cached `_site_id`, not a `site` FK; it is what
354 # virtualization.signals propagates to the cluster's VMs, shifting their site matching.
355 ('virtualization', 'Cluster', ('type_id', 'group_id', '_site_id'), 'cluster_id', 'cluster_id'),
356 ('tenancy', 'Tenant', ('group_id',), 'tenant_id', 'tenant_id'),
357 )
358 for app, name, fields, device_lookup, vm_lookup in direct_triggers:
359 Model = django_apps.get_model(app, name)
360 post_save.connect(
361 _make_direct_upstream_handler(fields, device_lookup, vm_lookup),
362 sender=Model,
363 weak=False,
364 )
366 # (app, model, device_attr_path__in, vm_attr_path__in)
367 reparent_triggers = (
368 ('dcim', 'Region', 'site__region__in', 'site__region__in'),
369 ('dcim', 'SiteGroup', 'site__group__in', 'site__group__in'),
370 ('dcim', 'DeviceRole', 'role__in', 'role__in'),
371 ('dcim', 'Platform', 'platform__in', 'platform__in'),
372 ('dcim', 'Location', 'location__in', None),
373 )
374 for app, name, device_attr, vm_attr in reparent_triggers:
375 Model = django_apps.get_model(app, name)
376 post_save.connect(
377 _make_reparent_handler(device_attr, vm_attr),
378 sender=Model,
379 weak=False,
380 )
383_connect_upstream_handlers()
386# Deletion of an upstream object referenced by a Device/VM via a SET_NULL foreign key (or by a
387# Site/Tenant the object belongs to) silently nulls that FK with a bulk UPDATE that emits no
388# post_save signal, so the object-save handlers above never fire. We therefore invalidate on
389# pre_delete, while the references are still resolvable.
390#
391# Only SET_NULL relationships matter here: PROTECT relationships (Device.role/tenant/site,
392# VM.cluster/site/role/tenant, etc.) cannot be deleted while a Device/VM references them, so no
393# stale cache can result. The SET_NULL feeders into ConfigContext matching are:
394# - Platform (Device.platform, VM.platform)
395# - Cluster (Device.cluster) -> also covers cluster_type/cluster_group scopes
396# - Region (Site.region)
397# - SiteGroup (Site.group)
398# - TenantGroup (Tenant.group)
399#
400# We reuse invalidate_for_scope_delta(), which resolves the full set of Devices/VMs reachable via
401# the given scope dimension (descendants included for nested/ltree models), exactly matching the objects
402# whose FK is about to be nulled.
404def _make_upstream_delete_handler(scope_field):
405 def _handler(sender, instance, using=None, **kwargs):
406 invalidate_for_scope_delta(scope_field, [instance.pk], using=using)
408 return _handler
411def _connect_upstream_delete_handlers():
412 from django.apps import apps as django_apps
414 # (app, model, scope_field)
415 delete_triggers = (
416 ('dcim', 'Platform', 'platforms'),
417 ('dcim', 'Region', 'regions'),
418 ('dcim', 'SiteGroup', 'site_groups'),
419 ('virtualization', 'Cluster', 'clusters'),
420 ('tenancy', 'TenantGroup', 'tenant_groups'),
421 )
422 for app, name, scope_field in delete_triggers:
423 Model = django_apps.get_model(app, name)
424 pre_delete.connect(
425 _make_upstream_delete_handler(scope_field),
426 sender=Model,
427 weak=False,
428 )
431_connect_upstream_delete_handlers()
434#
435# Event rules
436#
438@receiver(job_start)
439def process_job_start_event_rules(sender, **kwargs):
440 """
441 Process event rules for jobs starting.
442 """
443 event_rules = EventRule.objects.filter(
444 event_types__contains=[JOB_STARTED],
445 enabled=True,
446 object_types=sender.object_type
447 )
448 event = EventContext(
449 event_type=JOB_STARTED,
450 data=sender.data,
451 user=sender.user,
452 )
453 process_event_rules(event_rules, sender.object_type, event)
456@receiver(job_end)
457def process_job_end_event_rules(sender, **kwargs):
458 """
459 Process event rules for jobs terminating.
460 """
461 event_rules = EventRule.objects.filter(
462 event_types__contains=[JOB_COMPLETED],
463 enabled=True,
464 object_types=sender.object_type
465 )
466 event = EventContext(
467 event_type=JOB_COMPLETED,
468 data=sender.data,
469 user=sender.user,
470 )
471 process_event_rules(event_rules, sender.object_type, event)
474#
475# Notifications
476#
478@receiver((post_save, pre_delete))
479def notify_object_changed(sender, instance, **kwargs):
480 # Skip for newly-created objects
481 if kwargs.get('created'):
482 return
484 # Determine event type
485 if 'created' in kwargs:
486 event_type = OBJECT_UPDATED
487 else:
488 event_type = OBJECT_DELETED
490 # Skip unsupported object types
491 if not has_feature(instance, 'notifications'):
492 return
494 ct = ContentType.objects.get_for_model(instance)
496 # Find all subscribed Users
497 subscribed_users = Subscription.objects.filter(
498 object_type=ct,
499 object_id=instance.pk
500 ).values_list('user', flat=True)
501 if not subscribed_users: 501 ↛ 505line 501 didn't jump to line 505 because the condition on line 501 was always true
502 return
504 # Delete any existing Notifications for the object
505 Notification.objects.filter(
506 object_type=ct,
507 object_id=instance.pk,
508 user__in=subscribed_users
509 ).delete()
511 # Create Notifications for Subscribers
512 Notification.objects.bulk_create([
513 Notification(
514 user_id=user,
515 object=instance,
516 object_repr=Notification.get_object_repr(instance),
517 event_type=event_type
518 )
519 for user in subscribed_users
520 ])