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

1import os 

2from datetime import datetime 

3from typing import List 

4 

5from django.conf import settings 

6from django.db.models import Count, Q 

7from ninja import Router, Schema 

8 

9from .models import Profile 

10 

11router = Router(tags=['profiles']) 

12 

13 

14class ProfileIn(Schema): 

15 name: str 

16 avatar_color: str 

17 theme: str = 'light' 

18 unit_preference: str = 'metric' 

19 

20 

21class ProfileOut(Schema): 

22 id: int 

23 name: str 

24 avatar_color: str 

25 theme: str 

26 unit_preference: str 

27 

28 

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 

37 

38 

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 

47 

48 

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 

58 

59 

60class ProfileSummarySchema(Schema): 

61 id: int 

62 name: str 

63 avatar_color: str 

64 created_at: datetime 

65 

66 

67class DeletionPreviewSchema(Schema): 

68 profile: ProfileSummarySchema 

69 data_to_delete: DeletionDataSchema 

70 warnings: List[str] 

71 

72 

73class ErrorSchema(Schema): 

74 error: str 

75 message: str 

76 

77 

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 

82 

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

95 

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

102 

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 

121 

122 

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 

128 

129 

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) 

134 

135 

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 

144 

145 

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 ) 

158 

159 try: 

160 profile = Profile.objects.get(id=profile_id) 

161 except Profile.DoesNotExist: 

162 return 404, {'error': 'not_found', 'message': 'Profile not found'} 

163 

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) 

172 

173 # Count images that will be deleted 

174 remix_images_count = remixes.exclude(image='').exclude(image__isnull=True).count() 

175 

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 } 

199 

200 

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. 

205 

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 

213 

214 Manual cleanup: 

215 - Recipe images from deleted remixes 

216 """ 

217 from apps.recipes.models import Recipe 

218 

219 try: 

220 profile = Profile.objects.get(id=profile_id) 

221 except Profile.DoesNotExist: 

222 return 404, {'error': 'not_found', 'message': 'Profile not found'} 

223 

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'] 

229 

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 ) 

238 

239 # Django CASCADE handles all related records 

240 profile.delete() 

241 

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 

251 

252 return 204, None 

253 

254 

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 

← Back to Dashboard