## Summary - Closes [#34](#34) - Companion for [chat_web_app#75](ai_ml_operations/chat_web_app#75) (portal cancel/change local sync) - Soft-delete `DELETE /api/user/` for the authenticated user only: `deleted=True`, `is_active=False`, hide conversations, blacklist outstanding refresh tokens; staff self-delete rejected - Stripe `customer.subscription.updated` / `deleted` webhooks sync plan status, `cancel_at_period_end`, and `current_period_end`; checkout assigns plan from `metadata.plan_slug` - **UserAuthEvent audit**: `account_deleted`, `subscription_started` (first active plan), `subscription_updated` (plan/status/cancel changes) — visible on user admin - Document FE contract in README (endpoint, response, post-delete logout) ## Test plan - [ ] `uv run python manage.py test chat_backend.tests.test_views_users.CustomUserSelfDeleteTestCase finance.tests` - [ ] Authenticated `DELETE /api/user/` soft-deletes self, hides conversations, blocks re-login, writes `account_deleted` auth event - [ ] Checkout / Backer assign writes `subscription_started`; portal cancel/change writes `subscription_updated` - [ ] Anonymous / staff self-delete rejected; body cannot target another user - [ ] After portal cancel, webhook sets `cancel_at_period_end` / `canceled` on `GET /finance/subscription/`Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""Self-service account soft-delete helpers (#34)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
OutstandingToken,
|
||||
)
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from chat_backend.models import Conversation, CustomUser, UserAuthEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountDeletionError(Exception):
|
||||
"""Raised when self-delete is not allowed for the requesting user."""
|
||||
|
||||
def __init__(self, detail: str, *, code: str = "delete_forbidden"):
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
self.code = code
|
||||
|
||||
|
||||
def _blacklist_outstanding_tokens(user: CustomUser) -> int:
|
||||
"""Blacklist all outstanding refresh tokens for the user. Returns count."""
|
||||
count = 0
|
||||
for outstanding in OutstandingToken.objects.filter(user=user):
|
||||
_token, created = BlacklistedToken.objects.get_or_create(token=outstanding)
|
||||
if created:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _blacklist_refresh_token(refresh_token: str | None) -> None:
|
||||
if not refresh_token:
|
||||
return
|
||||
try:
|
||||
RefreshToken(refresh_token).blacklist()
|
||||
except Exception:
|
||||
logger.info("Self-delete: optional refresh token could not be blacklisted")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def soft_delete_account(
|
||||
user: CustomUser,
|
||||
*,
|
||||
refresh_token: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> CustomUser:
|
||||
"""
|
||||
Soft-delete the requesting user and hide their conversations.
|
||||
|
||||
Privacy (v1): personal data is retained under soft-delete for admin/audit.
|
||||
Full purge (chats, documents, RAG vectors, auth events) is a follow-up.
|
||||
"""
|
||||
if user.is_staff or user.is_superuser:
|
||||
raise AccountDeletionError(
|
||||
"Staff accounts cannot self-delete. Contact an administrator.",
|
||||
code="staff_forbidden",
|
||||
)
|
||||
|
||||
if user.deleted:
|
||||
raise AccountDeletionError(
|
||||
"This account has already been deleted.",
|
||||
code="already_deleted",
|
||||
)
|
||||
|
||||
user.deleted = True
|
||||
user.is_active = False
|
||||
user.save(update_fields=["deleted", "is_active"])
|
||||
|
||||
Conversation.objects.filter(user=user, deleted=False).update(deleted=True)
|
||||
|
||||
UserAuthEvent.log(
|
||||
user,
|
||||
UserAuthEvent.EventType.ACCOUNT_DELETED,
|
||||
detail="Self-service account soft-delete",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
_blacklist_refresh_token(refresh_token)
|
||||
blacklisted = _blacklist_outstanding_tokens(user)
|
||||
logger.info(
|
||||
"Soft-deleted user pk=%s; blacklisted_outstanding=%s",
|
||||
user.pk,
|
||||
blacklisted,
|
||||
)
|
||||
return user
|
||||
Reference in New Issue
Block a user