Coverage for apps / profiles / api.py: 79%
121 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
1import os
2from datetime import datetime
3from typing import List
5from django.conf import settings
6from django.db.models import Count, Q
7from ninja import Router, Schema
9from .models import Profile
11router = Router(tags=["profiles"])
14class ProfileIn(Schema):
15 name: str
16 avatar_color: str
17 theme: str = "light"
18 unit_preference: str = "metric"
21class ProfileOut(Schema):
22 id: int
23 name: str
24 avatar_color: str
25 theme: str
26 unit_preference: str
29class ProfileStatsSchema(Schema):
30 favorites: int
31 collections: int
32 collection_items: int
33 remixes: int
34 view_history: int
35 scaling_cache: int
36 discover_cache: int
39class ProfileWithStatsSchema(Schema):
40 id: int
41 name: str
42 avatar_color: str
43 theme: str
44 unit_preference: str
45 created_at: datetime
46 stats: ProfileStatsSchema
49class DeletionDataSchema(Schema):
50 remixes: int
51 remix_images: int
52 favorites: int
53 collections: int
54 collection_items: int
55 view_history: int
56 scaling_cache: int
57 discover_cache: int
60class ProfileSummarySchema(Schema):
61 id: int
62 name: str
63 avatar_color: str
64 created_at: datetime
67class DeletionPreviewSchema(Schema):
68 profile: ProfileSummarySchema
69 data_to_delete: DeletionDataSchema
70 warnings: List[str]
73class ErrorSchema(Schema):
74 error: str
75 message: str
78@router.get("/", response=List[ProfileWithStatsSchema])
79def list_profiles(request):
80 """List all profiles with stats for user management."""
81 from apps.recipes.models import RecipeCollectionItem
83 profiles = Profile.objects.annotate(
84 favorites_count=Count("favorites", distinct=True),
85 collections_count=Count("collections", distinct=True),
86 remixes_count=Count("remixes", filter=Q(remixes__is_remix=True), distinct=True),
87 view_history_count=Count("view_history", distinct=True),
88 scaling_cache_count=Count("serving_adjustments", distinct=True),
89 discover_cache_count=Count("ai_discovery_suggestions", distinct=True),
90 ).order_by("-created_at")
92 result = []
93 for p in profiles:
94 # Count collection items separately (requires join)
95 collection_items_count = RecipeCollectionItem.objects.filter(collection__profile=p).count()
97 result.append(
98 ProfileWithStatsSchema(
99 id=p.id,
100 name=p.name,
101 avatar_color=p.avatar_color,
102 theme=p.theme,
103 unit_preference=p.unit_preference,
104 created_at=p.created_at,
105 stats=ProfileStatsSchema(
106 favorites=p.favorites_count,
107 collections=p.collections_count,
108 collection_items=collection_items_count,
109 remixes=p.remixes_count,
110 view_history=p.view_history_count,
111 scaling_cache=p.scaling_cache_count,
112 discover_cache=p.discover_cache_count,
113 ),
114 )
115 )
116 return result
119@router.post("/", response={201: ProfileOut})
120def create_profile(request, payload: ProfileIn):
121 """Create a new profile."""
122 profile = Profile.objects.create(**payload.dict())
123 return 201, profile
126@router.get("/{profile_id}/", response=ProfileOut)
127def get_profile(request, profile_id: int):
128 """Get a profile by ID."""
129 return Profile.objects.get(id=profile_id)
132@router.put("/{profile_id}/", response=ProfileOut)
133def update_profile(request, profile_id: int, payload: ProfileIn):
134 """Update a profile."""
135 profile = Profile.objects.get(id=profile_id)
136 for key, value in payload.dict().items():
137 setattr(profile, key, value)
138 profile.save()
139 return profile
142@router.get("/{profile_id}/deletion-preview/", response={200: DeletionPreviewSchema, 404: ErrorSchema})
143def get_deletion_preview(request, profile_id: int):
144 """Get summary of data that will be deleted with this profile."""
145 from apps.ai.models import AIDiscoverySuggestion
146 from apps.recipes.models import (
147 Recipe,
148 RecipeCollection,
149 RecipeCollectionItem,
150 RecipeFavorite,
151 RecipeViewHistory,
152 ServingAdjustment,
153 )
155 try:
156 profile = Profile.objects.get(id=profile_id)
157 except Profile.DoesNotExist:
158 return 404, {"error": "not_found", "message": "Profile not found"}
160 # Count related data
161 remixes = Recipe.objects.filter(is_remix=True, remix_profile=profile)
162 favorites = RecipeFavorite.objects.filter(profile=profile)
163 collections = RecipeCollection.objects.filter(profile=profile)
164 collection_items = RecipeCollectionItem.objects.filter(collection__profile=profile)
165 view_history = RecipeViewHistory.objects.filter(profile=profile)
166 scaling_cache = ServingAdjustment.objects.filter(profile=profile)
167 discover_cache = AIDiscoverySuggestion.objects.filter(profile=profile)
169 # Count images that will be deleted
170 remix_images_count = remixes.exclude(image="").exclude(image__isnull=True).count()
172 return {
173 "profile": {
174 "id": profile.id,
175 "name": profile.name,
176 "avatar_color": profile.avatar_color,
177 "created_at": profile.created_at,
178 },
179 "data_to_delete": {
180 "remixes": remixes.count(),
181 "remix_images": remix_images_count,
182 "favorites": favorites.count(),
183 "collections": collections.count(),
184 "collection_items": collection_items.count(),
185 "view_history": view_history.count(),
186 "scaling_cache": scaling_cache.count(),
187 "discover_cache": discover_cache.count(),
188 },
189 "warnings": [
190 "All remixed recipes will be permanently deleted",
191 "Recipe images for remixes will be removed from storage",
192 "This action cannot be undone",
193 ],
194 }
197@router.delete("/{profile_id}/", response={204: None, 400: ErrorSchema, 404: ErrorSchema})
198def delete_profile(request, profile_id: int):
199 """
200 Delete a profile and ALL associated data.
202 Cascade deletes:
203 - Recipe remixes (is_remix=True, remix_profile=this)
204 - Favorites
205 - Collections and collection items
206 - View history
207 - Serving adjustment cache
208 - AI discovery suggestions
210 Manual cleanup:
211 - Recipe images from deleted remixes
212 """
213 from apps.recipes.models import Recipe
215 try:
216 profile = Profile.objects.get(id=profile_id)
217 except Profile.DoesNotExist:
218 return 404, {"error": "not_found", "message": "Profile not found"}
220 # Check if this is the current session profile
221 current_profile_id = request.session.get("profile_id")
222 if current_profile_id == profile_id:
223 # Clear session profile
224 del request.session["profile_id"]
226 # Collect image paths BEFORE cascade delete
227 remix_images = list(
228 Recipe.objects.filter(is_remix=True, remix_profile=profile, image__isnull=False)
229 .exclude(image="")
230 .values_list("image", flat=True)
231 )
233 # Django CASCADE handles all related records
234 profile.delete()
236 # Clean up orphaned image files
237 for image_path in remix_images:
238 full_path = os.path.join(settings.MEDIA_ROOT, str(image_path))
239 try:
240 if os.path.exists(full_path):
241 os.remove(full_path)
242 except OSError:
243 # Log but don't fail - orphaned files are non-critical
244 pass
246 return 204, None
249@router.post("/{profile_id}/select/", response={200: ProfileOut})
250def select_profile(request, profile_id: int):
251 """Set a profile as the current profile (stored in session)."""
252 profile = Profile.objects.get(id=profile_id)
253 request.session["profile_id"] = profile.id
254 return profile