Coverage for apps / profiles / api.py: 79%
121 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +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(
87 'remixes',
88 filter=Q(remixes__is_remix=True),
89 distinct=True
90 ),
91 view_history_count=Count('view_history', distinct=True),
92 scaling_cache_count=Count('serving_adjustments', distinct=True),
93 discover_cache_count=Count('ai_discovery_suggestions', distinct=True),
94 ).order_by('-created_at')
96 result = []
97 for p in profiles:
98 # Count collection items separately (requires join)
99 collection_items_count = RecipeCollectionItem.objects.filter(
100 collection__profile=p
101 ).count()
103 result.append(ProfileWithStatsSchema(
104 id=p.id,
105 name=p.name,
106 avatar_color=p.avatar_color,
107 theme=p.theme,
108 unit_preference=p.unit_preference,
109 created_at=p.created_at,
110 stats=ProfileStatsSchema(
111 favorites=p.favorites_count,
112 collections=p.collections_count,
113 collection_items=collection_items_count,
114 remixes=p.remixes_count,
115 view_history=p.view_history_count,
116 scaling_cache=p.scaling_cache_count,
117 discover_cache=p.discover_cache_count,
118 )
119 ))
120 return result
123@router.post('/', response={201: ProfileOut})
124def create_profile(request, payload: ProfileIn):
125 """Create a new profile."""
126 profile = Profile.objects.create(**payload.dict())
127 return 201, profile
130@router.get('/{profile_id}/', response=ProfileOut)
131def get_profile(request, profile_id: int):
132 """Get a profile by ID."""
133 return Profile.objects.get(id=profile_id)
136@router.put('/{profile_id}/', response=ProfileOut)
137def update_profile(request, profile_id: int, payload: ProfileIn):
138 """Update a profile."""
139 profile = Profile.objects.get(id=profile_id)
140 for key, value in payload.dict().items():
141 setattr(profile, key, value)
142 profile.save()
143 return profile
146@router.get('/{profile_id}/deletion-preview/', response={200: DeletionPreviewSchema, 404: ErrorSchema})
147def get_deletion_preview(request, profile_id: int):
148 """Get summary of data that will be deleted with this profile."""
149 from apps.ai.models import AIDiscoverySuggestion
150 from apps.recipes.models import (
151 Recipe,
152 RecipeCollection,
153 RecipeCollectionItem,
154 RecipeFavorite,
155 RecipeViewHistory,
156 ServingAdjustment,
157 )
159 try:
160 profile = Profile.objects.get(id=profile_id)
161 except Profile.DoesNotExist:
162 return 404, {'error': 'not_found', 'message': 'Profile not found'}
164 # Count related data
165 remixes = Recipe.objects.filter(is_remix=True, remix_profile=profile)
166 favorites = RecipeFavorite.objects.filter(profile=profile)
167 collections = RecipeCollection.objects.filter(profile=profile)
168 collection_items = RecipeCollectionItem.objects.filter(collection__profile=profile)
169 view_history = RecipeViewHistory.objects.filter(profile=profile)
170 scaling_cache = ServingAdjustment.objects.filter(profile=profile)
171 discover_cache = AIDiscoverySuggestion.objects.filter(profile=profile)
173 # Count images that will be deleted
174 remix_images_count = remixes.exclude(image='').exclude(image__isnull=True).count()
176 return {
177 'profile': {
178 'id': profile.id,
179 'name': profile.name,
180 'avatar_color': profile.avatar_color,
181 'created_at': profile.created_at,
182 },
183 'data_to_delete': {
184 'remixes': remixes.count(),
185 'remix_images': remix_images_count,
186 'favorites': favorites.count(),
187 'collections': collections.count(),
188 'collection_items': collection_items.count(),
189 'view_history': view_history.count(),
190 'scaling_cache': scaling_cache.count(),
191 'discover_cache': discover_cache.count(),
192 },
193 'warnings': [
194 'All remixed recipes will be permanently deleted',
195 'Recipe images for remixes will be removed from storage',
196 'This action cannot be undone',
197 ]
198 }
201@router.delete('/{profile_id}/', response={204: None, 400: ErrorSchema, 404: ErrorSchema})
202def delete_profile(request, profile_id: int):
203 """
204 Delete a profile and ALL associated data.
206 Cascade deletes:
207 - Recipe remixes (is_remix=True, remix_profile=this)
208 - Favorites
209 - Collections and collection items
210 - View history
211 - Serving adjustment cache
212 - AI discovery suggestions
214 Manual cleanup:
215 - Recipe images from deleted remixes
216 """
217 from apps.recipes.models import Recipe
219 try:
220 profile = Profile.objects.get(id=profile_id)
221 except Profile.DoesNotExist:
222 return 404, {'error': 'not_found', 'message': 'Profile not found'}
224 # Check if this is the current session profile
225 current_profile_id = request.session.get('profile_id')
226 if current_profile_id == profile_id:
227 # Clear session profile
228 del request.session['profile_id']
230 # Collect image paths BEFORE cascade delete
231 remix_images = list(
232 Recipe.objects.filter(
233 is_remix=True,
234 remix_profile=profile,
235 image__isnull=False
236 ).exclude(image='').values_list('image', flat=True)
237 )
239 # Django CASCADE handles all related records
240 profile.delete()
242 # Clean up orphaned image files
243 for image_path in remix_images:
244 full_path = os.path.join(settings.MEDIA_ROOT, str(image_path))
245 try:
246 if os.path.exists(full_path):
247 os.remove(full_path)
248 except OSError:
249 # Log but don't fail - orphaned files are non-critical
250 pass
252 return 204, None
255@router.post('/{profile_id}/select/', response={200: ProfileOut})
256def select_profile(request, profile_id: int):
257 """Set a profile as the current profile (stored in session)."""
258 profile = Profile.objects.get(id=profile_id)
259 request.session['profile_id'] = profile.id
260 return profile