Coverage for baibu/users/account_deletion_requests.py: 90%
39 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1import logging
3from django.conf import settings
4from django.core.mail import EmailMessage
5from django.db import IntegrityError
6from django.db import transaction
8from config.email_recipients import resolve_operations_recipients
10from .models import AccountDeletionRequest
11from .models import User
13logger = logging.getLogger(__name__)
16def capture_account_deletion_request( # noqa: PLR0913
17 *,
18 source: str,
19 user: User,
20 requester_name: str = "",
21 requester_email: str = "",
22 request_details: str = "",
23 metadata: dict | None = None,
24) -> tuple[AccountDeletionRequest, bool]:
25 """Record a deletion request and deactivate the account at once.
27 Returns the open request and whether it was newly created. A user has at
28 most one open request; asking again returns the existing one.
29 """
30 normalized = {
31 "requester_name": " ".join((requester_name or "").split()),
32 "requester_email": User.objects.normalize_email_or_none(requester_email) or "",
33 "request_details": (request_details or "").strip(),
34 "metadata": dict(metadata or {}),
35 }
36 try:
37 with transaction.atomic():
38 user = User.objects.select_for_update().get(pk=user.pk)
39 if user.is_active:
40 user.is_active = False
41 user.save(update_fields=["is_active"])
42 existing = (
43 AccountDeletionRequest.objects.select_for_update()
44 .filter(user=user, status__in=AccountDeletionRequest.OPEN_STATUSES)
45 .order_by("-requested_at")
46 .first()
47 )
48 if existing is not None:
49 return existing, False
50 deletion_request = AccountDeletionRequest.objects.create(source=source, user=user, **normalized)
51 transaction.on_commit(
52 lambda request_id=deletion_request.pk: _notify_operations_safely(request_id),
53 )
54 except IntegrityError:
55 # A concurrent request won the race; return the one that exists.
56 User.objects.filter(pk=user.pk, is_active=True).update(is_active=False)
57 existing = AccountDeletionRequest.objects.get(user=user, status__in=AccountDeletionRequest.OPEN_STATUSES)
58 return existing, False
59 return deletion_request, True
62def _notify_operations_safely(request_id) -> None:
63 try:
64 _notify_operations(request_id)
65 except Exception as exc: # noqa: BLE001
66 logger.error(
67 "Account deletion notification failed; request_id=%s error_type=%s",
68 request_id,
69 type(exc).__name__,
70 )
73def _notify_operations(request_id) -> None:
74 deletion_request = AccountDeletionRequest.objects.only("id", "source", "requested_at").get(pk=request_id)
75 recipients = resolve_operations_recipients()
76 if not recipients:
77 return
78 # Personal details stay out of email; staff read them in the admin.
79 body = "\n".join(
80 [
81 "An account deletion request was received.",
82 f"Receipt ID: {deletion_request.pk}",
83 f"Source: {deletion_request.source}",
84 f"Requested at: {deletion_request.requested_at.isoformat()}",
85 "",
86 "Review the request in the admin. Contact details and request text are left out of this email on purpose.",
87 ],
88 )
89 EmailMessage(
90 subject=f"{settings.EMAIL_SUBJECT_PREFIX}Account deletion request {deletion_request.pk}",
91 body=body,
92 from_email=settings.DEFAULT_FROM_EMAIL,
93 to=recipients,
94 ).send(fail_silently=False)