Coverage for apps / core / auth_api.py: 85%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1"""Authentication API endpoints — shared endpoints for passkey mode."""
3import logging
5from django.conf import settings
6from django.contrib.auth import logout
7from django.http import Http404
8from ninja import Router, Status
10from apps.core.auth import SessionAuth
11from apps.core.auth_helpers import passkey_user_profile_response
12from apps.profiles.deletion import (
13 collect_remix_image_paths,
14 get_deletion_preview,
15 remove_remix_image_files,
16)
17from apps.profiles.models import Profile
19security_logger = logging.getLogger("security")
21router = Router(tags=["auth"])
24def _require_auth_mode(request):
25 """Raise 404 if not in passkey mode (the only mode with user accounts)."""
26 if settings.AUTH_MODE != "passkey":
27 raise Http404
30# --- Endpoints ---
33@router.post("/logout/", response={200: dict}, auth=SessionAuth())
34def logout_view(request):
35 _require_auth_mode(request)
36 username = getattr(request, "user", None)
37 username = username.username if username and hasattr(username, "username") else "unknown"
38 logout(request)
39 request.session.flush()
40 security_logger.info("Logout: user=%s", username)
41 return {"message": "Logged out successfully"}
44@router.get("/me/", response={200: dict, 401: dict}, auth=SessionAuth())
45def get_me(request):
46 _require_auth_mode(request)
47 user = request.user
48 if not user or not getattr(user, "is_authenticated", False):
49 return Status(401, {"error": "Authentication required"})
51 try:
52 profile = user.profile
53 except Profile.DoesNotExist:
54 return Status(401, {"error": "Authentication required"})
56 return Status(200, passkey_user_profile_response(user, profile))
59@router.get(
60 "/me/deletion-preview/",
61 response={200: dict, 401: dict},
62 auth=SessionAuth(),
63)
64def get_me_deletion_preview(request):
65 """Preview the data that will be removed if the caller deletes their
66 own account. Passkey-mode counterpart of
67 `GET /api/profiles/{id}/deletion-preview/` (which is HomeOnly)."""
68 _require_auth_mode(request)
69 user = request.user
70 if not user or not getattr(user, "is_authenticated", False):
71 return Status(401, {"error": "Authentication required"})
72 try:
73 profile = user.profile
74 except Profile.DoesNotExist:
75 return Status(401, {"error": "Authentication required"})
76 return Status(200, get_deletion_preview(profile))
79@router.delete(
80 "/me/",
81 response={204: None, 401: dict},
82 auth=SessionAuth(),
83)
84def delete_me(request):
85 """Delete the caller's own account and all associated data. Passkey-mode
86 counterpart of `DELETE /api/profiles/{id}/` (which is HomeOnly).
88 Deletes, in order:
89 1. Snapshots remix image paths from the DB.
90 2. Deletes the User, which CASCADE-drops the Profile and all its
91 associated Recipe/Collection/Favorite/etc. rows.
92 3. Best-effort removes remix image files from media storage.
93 4. Flushes the session so the now-invalid cookie is 401 on replay.
95 Step ordering is deliberate: DB first (atomic), media cleanup after
96 (best-effort). A mid-delete crash leaves orphan image files — fine —
97 rather than orphan DB rows.
98 """
99 _require_auth_mode(request)
100 user = request.user
101 if not user or not getattr(user, "is_authenticated", False):
102 return Status(401, {"error": "Authentication required"})
103 try:
104 profile = user.profile
105 except Profile.DoesNotExist:
106 return Status(401, {"error": "Authentication required"})
108 username = user.username
109 image_paths = collect_remix_image_paths(profile)
110 # Deleting the User CASCADEs the Profile via its OneToOne FK.
111 user.delete()
112 remove_remix_image_files(image_paths)
113 request.session.flush()
114 security_logger.info("Self-delete: user=%s", username)
115 return Status(204, None)