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

1from datetime import datetime 

2from typing import List, Optional 

3 

4from django.conf import settings 

5from django.db.models import Count, Q 

6from django_ratelimit.decorators import ratelimit 

7from ninja import Router, Schema, Status 

8 

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 

16 

17router = Router(tags=["profiles"]) 

18 

19 

20class ProfileIn(Schema): 

21 name: str 

22 avatar_color: str = "" 

23 theme: str = "light" 

24 unit_preference: str = "metric" 

25 

26 

27class ProfileOut(Schema): 

28 id: int 

29 name: str 

30 avatar_color: str 

31 theme: str 

32 unit_preference: str 

33 

34 

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 

43 

44 

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 

54 

55 

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 

65 

66 

67class ProfileSummarySchema(Schema): 

68 id: int 

69 name: str 

70 avatar_color: str 

71 created_at: datetime 

72 

73 

74class DeletionPreviewSchema(Schema): 

75 profile: ProfileSummarySchema 

76 data_to_delete: DeletionDataSchema 

77 warnings: List[str] 

78 

79 

80class SetUnlimitedIn(Schema): 

81 unlimited: bool 

82 

83 

84class RenameIn(Schema): 

85 name: str 

86 

87 

88class PreferencesIn(Schema): 

89 """Display-preference payload for per-profile self-updates in both modes. 

90 

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. 

95 

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

100 

101 theme: Optional[str] = None 

102 unit_preference: Optional[str] = None 

103 

104 

105class ErrorSchema(Schema): 

106 error: str 

107 message: str 

108 

109 

110@router.get("/", response=List[ProfileWithStatsSchema], auth=HomeOnlyAnonAuth()) 

111def list_profiles(request): 

112 """List all profiles with stats. 

113 

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 

120 

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

129 

130 result = [] 

131 for p in profiles: 

132 collection_items_count = RecipeCollectionItem.objects.filter(collection__profile=p).count() 

133 

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 

155 

156 

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. 

161 

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) 

172 

173 

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

181 

182 

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 

194 

195 

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. 

206 

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

220 

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 ) 

228 

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 

235 

236 if not updates: 

237 # Nothing sent — return current profile, don't touch the DB. 

238 return caller_profile 

239 

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 

244 

245 

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) 

256 

257 

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

265 

266 current_profile_id = request.session.get("profile_id") 

267 if current_profile_id == profile_id: 

268 request.session.pop("profile_id", None) 

269 

270 image_paths = collect_remix_image_paths(profile) 

271 profile.delete() 

272 remove_remix_image_files(image_paths) 

273 

274 return Status(204, None) 

275 

276 

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

280 

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 

291 

292 

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} 

303 

304 

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} 

← Back to Dashboard