Coverage for apps / profiles / api.py: 92%
130 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
1from datetime import datetime
2from typing import List, Optional
4from django.conf import settings
5from django.db.models import Count, Q
6from django_ratelimit.decorators import ratelimit
7from ninja import Router, Schema, Status
9from apps.core.auth import HomeOnlyAnonAuth, HomeOnlyAuth, SessionAuth
10from .deletion import (
11 collect_remix_image_paths,
12 get_deletion_preview as _build_deletion_preview,
13 remove_remix_image_files,
14)
15from .models import Profile
17router = Router(tags=["profiles"])
20class ProfileIn(Schema):
21 name: str
22 avatar_color: str = ""
23 theme: str = "light"
24 unit_preference: str = "metric"
27class ProfileOut(Schema):
28 id: int
29 name: str
30 avatar_color: str
31 theme: str
32 unit_preference: str
35class ProfileStatsSchema(Schema):
36 favorites: int
37 collections: int
38 collection_items: int
39 remixes: int
40 view_history: int
41 scaling_cache: int
42 discover_cache: int
45class ProfileWithStatsSchema(Schema):
46 id: int
47 name: str
48 avatar_color: str
49 theme: str
50 unit_preference: str
51 unlimited_ai: bool = False
52 created_at: datetime
53 stats: ProfileStatsSchema
56class DeletionDataSchema(Schema):
57 remixes: int
58 remix_images: int
59 favorites: int
60 collections: int
61 collection_items: int
62 view_history: int
63 scaling_cache: int
64 discover_cache: int
67class ProfileSummarySchema(Schema):
68 id: int
69 name: str
70 avatar_color: str
71 created_at: datetime
74class DeletionPreviewSchema(Schema):
75 profile: ProfileSummarySchema
76 data_to_delete: DeletionDataSchema
77 warnings: List[str]
80class SetUnlimitedIn(Schema):
81 unlimited: bool
84class RenameIn(Schema):
85 name: str
88class PreferencesIn(Schema):
89 """Display-preference payload for per-profile self-updates in both modes.
91 Separated from ProfileIn so passkey-mode users can change their own theme
92 without touching identity fields (name, avatar_color) which remain
93 HomeOnly. Every field is optional — only sent fields are written, so
94 PATCH with {"theme": "dark"} is a noop on other fields.
96 Note: unit_preference is accepted to preserve API back-compat for older
97 clients but the endpoint rejects writes (feature disabled in v1.64). The
98 underlying column remains for read-only consumers (AI scaling).
99 """
101 theme: Optional[str] = None
102 unit_preference: Optional[str] = None
105class ErrorSchema(Schema):
106 error: str
107 message: str
110@router.get("/", response=List[ProfileWithStatsSchema], auth=HomeOnlyAnonAuth())
111def list_profiles(request):
112 """List all profiles with stats.
114 Home mode only — profile-selection screen, runs before any session exists.
115 HomeOnlyAnonAuth short-circuits to 404 in non-home modes via the route-gate
116 middleware (above URL dispatch), so probes cannot distinguish this path
117 from never-existed paths.
118 """
119 from apps.recipes.models import RecipeCollectionItem
121 profiles = Profile.objects.annotate(
122 favorites_count=Count("favorites", distinct=True),
123 collections_count=Count("collections", distinct=True),
124 remixes_count=Count("remixes", filter=Q(remixes__is_remix=True), distinct=True),
125 view_history_count=Count("view_history", distinct=True),
126 scaling_cache_count=Count("serving_adjustments", distinct=True),
127 discover_cache_count=Count("ai_discovery_suggestions", distinct=True),
128 ).order_by("-created_at")
130 result = []
131 for p in profiles:
132 collection_items_count = RecipeCollectionItem.objects.filter(collection__profile=p).count()
134 result.append(
135 ProfileWithStatsSchema(
136 id=p.id,
137 name=p.name,
138 avatar_color=p.avatar_color,
139 theme=p.theme,
140 unit_preference=p.unit_preference,
141 unlimited_ai=p.unlimited_ai,
142 created_at=p.created_at,
143 stats=ProfileStatsSchema(
144 favorites=p.favorites_count,
145 collections=p.collections_count,
146 collection_items=collection_items_count,
147 remixes=p.remixes_count,
148 view_history=p.view_history_count,
149 scaling_cache=p.scaling_cache_count,
150 discover_cache=p.discover_cache_count,
151 ),
152 )
153 )
154 return result
157@router.post("/", response={201: ProfileOut, 404: ErrorSchema, 429: ErrorSchema}, auth=HomeOnlyAnonAuth())
158@ratelimit(key="ip", rate="10/h", method="POST", block=False)
159def create_profile(request, payload: ProfileIn):
160 """Create a new profile. Home mode only — profile creation flow runs pre-session.
162 CSRF is enforced by HomeOnlyAnonAuth (via APIKeyCookie._get_key); mode-gate
163 short-circuits passkey probes to 404 above URL dispatch.
164 """
165 if getattr(request, "limited", False):
166 return Status(429, {"error": "rate_limited", "message": "Too many requests. Please try again later."})
167 data = payload.dict()
168 if not data.get("avatar_color"):
169 data["avatar_color"] = Profile.next_avatar_color()
170 profile = Profile.objects.create(**data)
171 return Status(201, profile)
174@router.get("/{profile_id}/", response={200: ProfileOut, 404: ErrorSchema}, auth=HomeOnlyAuth())
175def get_profile(request, profile_id: int):
176 """Get a profile by ID. Home mode only (404 in passkey via HomeOnlyAuth)."""
177 try:
178 return Profile.objects.get(id=profile_id)
179 except Profile.DoesNotExist:
180 return Status(404, {"error": "not_found", "message": "Profile not found"})
183@router.put("/{profile_id}/", response={200: ProfileOut, 404: ErrorSchema}, auth=HomeOnlyAuth())
184def update_profile(request, profile_id: int, payload: ProfileIn):
185 """Update a profile. Home mode only (404 in passkey via HomeOnlyAuth)."""
186 try:
187 profile = Profile.objects.get(id=profile_id)
188 except Profile.DoesNotExist:
189 return Status(404, {"error": "not_found", "message": "Profile not found"})
190 for key, value in payload.dict().items():
191 setattr(profile, key, value)
192 profile.save()
193 return profile
196@router.patch(
197 "/{profile_id}/preferences/",
198 response={200: ProfileOut, 400: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema},
199 auth=SessionAuth(),
200)
201def update_preferences(request, profile_id: int, payload: PreferencesIn):
202 """Update only display preferences (theme, unit_preference) for the
203 caller's own profile. Works in both home and passkey modes because the
204 identity fields (name, avatar_color) are NOT writable here — only
205 per-user display settings are. Callers must own the target profile.
207 Why this exists separately from PUT /profiles/{id}/:
208 In passkey mode the PUT variant is HomeOnlyAuth-gated (404) because
209 profile identity is tied to the authenticated passkey user and cannot
210 be reassigned mid-session. But users still need to flip dark-mode and
211 change unit preferences — those are personal display settings, not
212 identity. Round 10 regression guard: before this endpoint existed,
213 `toggleTheme` called PUT /profiles/{id}/ which 404'd in passkey mode,
214 making the UI briefly flip theme then roll back on the caught error.
215 """
216 # Ownership check: caller's profile.id must match the target.
217 caller_profile = request.auth
218 if not caller_profile or caller_profile.id != profile_id:
219 return Status(403, {"error": "forbidden", "message": "Cannot modify another profile"})
221 # Reject unit_preference writes — UI toggle hidden in v1.64 because conversion
222 # is only wired into AI scaling, not recipe display / scrape / cook mode.
223 if payload.unit_preference is not None:
224 return Status(
225 400,
226 {"error": "feature_disabled", "message": "unit_preference is not currently configurable"},
227 )
229 # Validate allowed values. Reject unknown theme strings — no free-form input.
230 updates: dict[str, str] = {}
231 if payload.theme is not None:
232 if payload.theme not in ("light", "dark"):
233 return Status(400, {"error": "validation_error", "message": "theme must be 'light' or 'dark'"})
234 updates["theme"] = payload.theme
236 if not updates:
237 # Nothing sent — return current profile, don't touch the DB.
238 return caller_profile
240 for key, value in updates.items():
241 setattr(caller_profile, key, value)
242 caller_profile.save(update_fields=list(updates.keys()))
243 return caller_profile
246@router.get(
247 "/{profile_id}/deletion-preview/", response={200: DeletionPreviewSchema, 404: ErrorSchema}, auth=HomeOnlyAuth()
248)
249def get_deletion_preview(request, profile_id: int):
250 """Get summary of data that will be deleted. Home mode only."""
251 try:
252 profile = Profile.objects.get(id=profile_id)
253 except Profile.DoesNotExist:
254 return Status(404, {"error": "not_found", "message": "Profile not found"})
255 return _build_deletion_preview(profile)
258@router.delete("/{profile_id}/", response={204: None, 400: ErrorSchema, 404: ErrorSchema}, auth=HomeOnlyAuth())
259def delete_profile(request, profile_id: int):
260 """Delete a profile and ALL associated data. Home mode only (404 in passkey via HomeOnlyAuth)."""
261 try:
262 profile = Profile.objects.get(id=profile_id)
263 except Profile.DoesNotExist:
264 return Status(404, {"error": "not_found", "message": "Profile not found"})
266 current_profile_id = request.session.get("profile_id")
267 if current_profile_id == profile_id:
268 request.session.pop("profile_id", None)
270 image_paths = collect_remix_image_paths(profile)
271 profile.delete()
272 remove_remix_image_files(image_paths)
274 return Status(204, None)
277@router.post("/{profile_id}/select/", response={200: ProfileOut, 404: dict}, auth=HomeOnlyAnonAuth())
278def select_profile(request, profile_id: int):
279 """Set a profile as the current profile. Home mode only (pre-session selection).
281 CSRF is enforced by HomeOnlyAnonAuth (via APIKeyCookie._get_key); mode-gate
282 short-circuits passkey probes to 404 above URL dispatch.
283 """
284 try:
285 profile = Profile.objects.get(id=profile_id)
286 except Profile.DoesNotExist:
287 request.session.pop("profile_id", None)
288 return Status(404, {"detail": "Profile not found"})
289 request.session["profile_id"] = profile.id
290 return profile
293@router.post("/{profile_id}/set-unlimited/", response={200: dict, 404: ErrorSchema}, auth=HomeOnlyAuth())
294def set_unlimited(request, profile_id: int, data: SetUnlimitedIn):
295 """Set or revoke unlimited AI access for a profile. Admin only."""
296 try:
297 profile = Profile.objects.get(id=profile_id)
298 except Profile.DoesNotExist:
299 return Status(404, {"error": "not_found", "message": "Profile not found"})
300 profile.unlimited_ai = data.unlimited
301 profile.save(update_fields=["unlimited_ai"])
302 return {"id": profile.id, "name": profile.name, "unlimited_ai": profile.unlimited_ai}
305@router.patch("/{profile_id}/rename/", response={200: dict, 400: ErrorSchema, 404: ErrorSchema}, auth=HomeOnlyAuth())
306def rename_profile(request, profile_id: int, data: RenameIn):
307 """Rename a profile. Admin only."""
308 name = data.name.strip()
309 if not name or len(name) > 100:
310 return Status(400, {"error": "validation_error", "message": "Name must be between 1 and 100 characters"})
311 try:
312 profile = Profile.objects.get(id=profile_id)
313 except Profile.DoesNotExist:
314 return Status(404, {"error": "not_found", "message": "Profile not found"})
315 profile.name = name
316 profile.save(update_fields=["name"])
317 return {"id": profile.id, "name": profile.name, "avatar_color": profile.avatar_color}