Coverage for apps / recipes / api_user.py: 99%

161 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 19:13 +0000

1""" 

2User feature API endpoints: favorites, collections, history. 

3 

4All endpoints are scoped to the current profile (from session). 

5""" 

6 

7from typing import List, Optional 

8 

9from django.db import IntegrityError 

10from django.db.models import Max 

11from django.shortcuts import get_object_or_404 

12from ninja import Router, Schema 

13 

14from apps.profiles.utils import get_current_profile 

15 

16from .api import RecipeListOut 

17from .models import ( 

18 Recipe, 

19 RecipeCollection, 

20 RecipeCollectionItem, 

21 RecipeFavorite, 

22 RecipeViewHistory, 

23) 

24 

25# ============================================================================= 

26# Favorites Router 

27# ============================================================================= 

28 

29favorites_router = Router(tags=["favorites"]) 

30 

31 

32class FavoriteIn(Schema): 

33 recipe_id: int 

34 

35 

36class FavoriteOut(Schema): 

37 recipe: RecipeListOut 

38 created_at: str 

39 

40 @staticmethod 

41 def resolve_created_at(obj): 

42 return obj.created_at.isoformat() 

43 

44 

45class ErrorOut(Schema): 

46 detail: str 

47 

48 

49@favorites_router.get("/", response=List[FavoriteOut]) 

50def list_favorites(request): 

51 """List all favorites for the current profile.""" 

52 profile = get_current_profile(request) 

53 return RecipeFavorite.objects.filter(profile=profile).select_related("recipe") 

54 

55 

56@favorites_router.post("/", response={201: FavoriteOut, 400: ErrorOut, 404: ErrorOut}) 

57def add_favorite(request, payload: FavoriteIn): 

58 """Add a recipe to favorites.""" 

59 profile = get_current_profile(request) 

60 recipe = get_object_or_404(Recipe, id=payload.recipe_id) 

61 

62 try: 

63 favorite = RecipeFavorite.objects.create(profile=profile, recipe=recipe) 

64 return 201, favorite 

65 except IntegrityError: 

66 return 400, {"detail": "Recipe is already a favorite"} 

67 

68 

69@favorites_router.delete("/{recipe_id}/", response={204: None, 404: ErrorOut}) 

70def remove_favorite(request, recipe_id: int): 

71 """Remove a recipe from favorites.""" 

72 profile = get_current_profile(request) 

73 favorite = get_object_or_404(RecipeFavorite, profile=profile, recipe_id=recipe_id) 

74 favorite.delete() 

75 return 204, None 

76 

77 

78# ============================================================================= 

79# Collections Router 

80# ============================================================================= 

81 

82collections_router = Router(tags=["collections"]) 

83 

84 

85class CollectionIn(Schema): 

86 name: str 

87 description: str = "" 

88 

89 

90class CollectionItemIn(Schema): 

91 recipe_id: int 

92 

93 

94class CollectionItemOut(Schema): 

95 recipe: RecipeListOut 

96 order: int 

97 added_at: str 

98 

99 @staticmethod 

100 def resolve_added_at(obj): 

101 return obj.added_at.isoformat() 

102 

103 

104class CollectionOut(Schema): 

105 id: int 

106 name: str 

107 description: str 

108 recipe_count: int 

109 created_at: str 

110 updated_at: str 

111 

112 @staticmethod 

113 def resolve_recipe_count(obj): 

114 return obj.items.count() 

115 

116 @staticmethod 

117 def resolve_created_at(obj): 

118 return obj.created_at.isoformat() 

119 

120 @staticmethod 

121 def resolve_updated_at(obj): 

122 return obj.updated_at.isoformat() 

123 

124 

125class CollectionDetailOut(Schema): 

126 id: int 

127 name: str 

128 description: str 

129 recipes: List[CollectionItemOut] 

130 created_at: str 

131 updated_at: str 

132 

133 @staticmethod 

134 def resolve_recipes(obj): 

135 return obj.items.select_related("recipe").all() 

136 

137 @staticmethod 

138 def resolve_created_at(obj): 

139 return obj.created_at.isoformat() 

140 

141 @staticmethod 

142 def resolve_updated_at(obj): 

143 return obj.updated_at.isoformat() 

144 

145 

146@collections_router.get("/", response=List[CollectionOut]) 

147def list_collections(request): 

148 """List all collections for the current profile.""" 

149 profile = get_current_profile(request) 

150 return RecipeCollection.objects.filter(profile=profile).prefetch_related("items") 

151 

152 

153@collections_router.post("/", response={201: CollectionOut, 400: ErrorOut}) 

154def create_collection(request, payload: CollectionIn): 

155 """Create a new collection.""" 

156 profile = get_current_profile(request) 

157 

158 try: 

159 collection = RecipeCollection.objects.create( 

160 profile=profile, 

161 name=payload.name, 

162 description=payload.description, 

163 ) 

164 return 201, collection 

165 except IntegrityError: 

166 return 400, {"detail": "A collection with this name already exists"} 

167 

168 

169@collections_router.get("/{collection_id}/", response={200: CollectionDetailOut, 404: ErrorOut}) 

170def get_collection(request, collection_id: int): 

171 """Get a collection with its recipes.""" 

172 profile = get_current_profile(request) 

173 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

174 return collection 

175 

176 

177@collections_router.put("/{collection_id}/", response={200: CollectionOut, 400: ErrorOut, 404: ErrorOut}) 

178def update_collection(request, collection_id: int, payload: CollectionIn): 

179 """Update a collection.""" 

180 profile = get_current_profile(request) 

181 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

182 

183 try: 

184 collection.name = payload.name 

185 collection.description = payload.description 

186 collection.save() 

187 return collection 

188 except IntegrityError: 

189 return 400, {"detail": "A collection with this name already exists"} 

190 

191 

192@collections_router.delete("/{collection_id}/", response={204: None, 404: ErrorOut}) 

193def delete_collection(request, collection_id: int): 

194 """Delete a collection.""" 

195 profile = get_current_profile(request) 

196 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

197 collection.delete() 

198 return 204, None 

199 

200 

201@collections_router.post("/{collection_id}/recipes/", response={201: CollectionItemOut, 400: ErrorOut, 404: ErrorOut}) 

202def add_recipe_to_collection(request, collection_id: int, payload: CollectionItemIn): 

203 """Add a recipe to a collection.""" 

204 profile = get_current_profile(request) 

205 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

206 recipe = get_object_or_404(Recipe, id=payload.recipe_id) 

207 

208 # Get the next order value 

209 max_order = collection.items.aggregate(max_order=Max("order"))["max_order"] 

210 next_order = (max_order or 0) + 1 

211 

212 try: 

213 item = RecipeCollectionItem.objects.create( 

214 collection=collection, 

215 recipe=recipe, 

216 order=next_order, 

217 ) 

218 return 201, item 

219 except IntegrityError: 

220 return 400, {"detail": "Recipe is already in this collection"} 

221 

222 

223@collections_router.delete("/{collection_id}/recipes/{recipe_id}/", response={204: None, 404: ErrorOut}) 

224def remove_recipe_from_collection(request, collection_id: int, recipe_id: int): 

225 """Remove a recipe from a collection.""" 

226 profile = get_current_profile(request) 

227 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

228 item = get_object_or_404(RecipeCollectionItem, collection=collection, recipe_id=recipe_id) 

229 item.delete() 

230 return 204, None 

231 

232 

233# ============================================================================= 

234# History Router 

235# ============================================================================= 

236 

237history_router = Router(tags=["history"]) 

238 

239 

240class HistoryIn(Schema): 

241 recipe_id: int 

242 

243 

244class HistoryOut(Schema): 

245 recipe: RecipeListOut 

246 viewed_at: str 

247 

248 @staticmethod 

249 def resolve_viewed_at(obj): 

250 return obj.viewed_at.isoformat() 

251 

252 

253@history_router.get("/", response=List[HistoryOut]) 

254def list_history(request, limit: int = 6): 

255 """ 

256 Get recently viewed recipes for the current profile. 

257 

258 Returns up to 6 most recent recipes by default. 

259 """ 

260 profile = get_current_profile(request) 

261 return RecipeViewHistory.objects.filter(profile=profile).select_related("recipe")[:limit] 

262 

263 

264@history_router.post("/", response={200: HistoryOut, 201: HistoryOut, 404: ErrorOut}) 

265def record_view(request, payload: HistoryIn): 

266 """ 

267 Record a recipe view. 

268 

269 If the recipe was already viewed, updates the timestamp. 

270 """ 

271 profile = get_current_profile(request) 

272 recipe = get_object_or_404(Recipe, id=payload.recipe_id) 

273 

274 history, created = RecipeViewHistory.objects.update_or_create( 

275 profile=profile, 

276 recipe=recipe, 

277 defaults={}, # viewed_at auto-updates due to auto_now 

278 ) 

279 

280 status = 201 if created else 200 

281 return status, history 

282 

283 

284@history_router.delete("/", response={204: None}) 

285def clear_history(request): 

286 """Clear all view history for the current profile.""" 

287 profile = get_current_profile(request) 

288 RecipeViewHistory.objects.filter(profile=profile).delete() 

289 return 204, None 

← Back to Dashboard