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

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

91 

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

96 

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 

117 

118 

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 

124 

125 

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) 

130 

131 

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 

140 

141 

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 ) 

154 

155 try: 

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

157 except Profile.DoesNotExist: 

158 return 404, {"error": "not_found", "message": "Profile not found"} 

159 

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) 

168 

169 # Count images that will be deleted 

170 remix_images_count = remixes.exclude(image="").exclude(image__isnull=True).count() 

171 

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 } 

195 

196 

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. 

201 

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 

209 

210 Manual cleanup: 

211 - Recipe images from deleted remixes 

212 """ 

213 from apps.recipes.models import Recipe 

214 

215 try: 

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

217 except Profile.DoesNotExist: 

218 return 404, {"error": "not_found", "message": "Profile not found"} 

219 

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

225 

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 ) 

232 

233 # Django CASCADE handles all related records 

234 profile.delete() 

235 

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 

245 

246 return 204, None 

247 

248 

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 

← Back to Dashboard