Coverage for extras/signals.py: 62%

181 statements  

« 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 

4 

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 

15 

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 

24 

25# 

26# Custom fields 

27# 

28 

29 

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. 

33 

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. 

37 

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 

45 

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 

52 

53 object_types = ContentType.objects.filter(pk__in=pk_set) 

54 

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) 

62 

63 

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) 

70 

71 

72def handle_cf_deleted(instance, **kwargs): 

73 """ 

74 Handle the cleanup of old custom field data when a CustomField is deleted. 

75 

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()) 

81 

82 

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. 

87 

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. 

91 

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 

98 

99 CustomField.objects.clear_cache() 

100 

101 

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) 

105 

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) 

109 

110 

111# 

112# Custom validation 

113# 

114 

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=[]) 

122 

123 run_validators(instance, validators) 

124 

125 

126# 

127# Tags 

128# 

129 

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.") 

142 

143 

144# 

145# Config context cache invalidation 

146# 

147 

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) 

156 

157 

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) 

166 

167 

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. 

172 

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 

183 

184 # Always invalidate based on the current (post-change) scope. 

185 invalidate_config_context_for_configcontext(instance, using=using) 

186 

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) 

190 

191 

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 

201 

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 ) 

212 

213 m2m_changed.connect(_handler, sender=through, weak=False) 

214 

215 

216_connect_configcontext_m2m_handlers() 

217 

218 

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 

236 

237 

238def _make_object_save_handler(model_label): 

239 fields = CC_FIELDS_BY_MODEL[model_label] 

240 

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) 

246 

247 return _handler 

248 

249 

250def _connect_object_save_handlers(): 

251 from django.apps import apps as django_apps 

252 

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) 

256 

257 

258_connect_object_save_handlers() 

259 

260 

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 

270 

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) 

275 

276 

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. 

285 

286 

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 

293 

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 ) 

306 

307 return _handler 

308 

309 

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 

316 

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 ) 

329 

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 ) 

342 

343 return _handler 

344 

345 

346def _connect_upstream_handlers(): 

347 from django.apps import apps as django_apps 

348 

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 ) 

365 

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 ) 

381 

382 

383_connect_upstream_handlers() 

384 

385 

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. 

403 

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) 

407 

408 return _handler 

409 

410 

411def _connect_upstream_delete_handlers(): 

412 from django.apps import apps as django_apps 

413 

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 ) 

429 

430 

431_connect_upstream_delete_handlers() 

432 

433 

434# 

435# Event rules 

436# 

437 

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) 

454 

455 

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) 

472 

473 

474# 

475# Notifications 

476# 

477 

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 

483 

484 # Determine event type 

485 if 'created' in kwargs: 

486 event_type = OBJECT_UPDATED 

487 else: 

488 event_type = OBJECT_DELETED 

489 

490 # Skip unsupported object types 

491 if not has_feature(instance, 'notifications'): 

492 return 

493 

494 ct = ContentType.objects.get_for_model(instance) 

495 

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 

503 

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() 

510 

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 ])