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

161 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:40 +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( 

74 RecipeFavorite, profile=profile, recipe_id=recipe_id 

75 ) 

76 favorite.delete() 

77 return 204, None 

78 

79 

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

81# Collections Router 

82# ============================================================================= 

83 

84collections_router = Router(tags=['collections']) 

85 

86 

87class CollectionIn(Schema): 

88 name: str 

89 description: str = '' 

90 

91 

92class CollectionItemIn(Schema): 

93 recipe_id: int 

94 

95 

96class CollectionItemOut(Schema): 

97 recipe: RecipeListOut 

98 order: int 

99 added_at: str 

100 

101 @staticmethod 

102 def resolve_added_at(obj): 

103 return obj.added_at.isoformat() 

104 

105 

106class CollectionOut(Schema): 

107 id: int 

108 name: str 

109 description: str 

110 recipe_count: int 

111 created_at: str 

112 updated_at: str 

113 

114 @staticmethod 

115 def resolve_recipe_count(obj): 

116 return obj.items.count() 

117 

118 @staticmethod 

119 def resolve_created_at(obj): 

120 return obj.created_at.isoformat() 

121 

122 @staticmethod 

123 def resolve_updated_at(obj): 

124 return obj.updated_at.isoformat() 

125 

126 

127class CollectionDetailOut(Schema): 

128 id: int 

129 name: str 

130 description: str 

131 recipes: List[CollectionItemOut] 

132 created_at: str 

133 updated_at: str 

134 

135 @staticmethod 

136 def resolve_recipes(obj): 

137 return obj.items.select_related('recipe').all() 

138 

139 @staticmethod 

140 def resolve_created_at(obj): 

141 return obj.created_at.isoformat() 

142 

143 @staticmethod 

144 def resolve_updated_at(obj): 

145 return obj.updated_at.isoformat() 

146 

147 

148@collections_router.get('/', response=List[CollectionOut]) 

149def list_collections(request): 

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

151 profile = get_current_profile(request) 

152 return RecipeCollection.objects.filter(profile=profile).prefetch_related('items') 

153 

154 

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

156def create_collection(request, payload: CollectionIn): 

157 """Create a new collection.""" 

158 profile = get_current_profile(request) 

159 

160 try: 

161 collection = RecipeCollection.objects.create( 

162 profile=profile, 

163 name=payload.name, 

164 description=payload.description, 

165 ) 

166 return 201, collection 

167 except IntegrityError: 

168 return 400, {'detail': 'A collection with this name already exists'} 

169 

170 

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

172def get_collection(request, collection_id: int): 

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

174 profile = get_current_profile(request) 

175 collection = get_object_or_404( 

176 RecipeCollection, id=collection_id, profile=profile 

177 ) 

178 return collection 

179 

180 

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

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

183 """Update a collection.""" 

184 profile = get_current_profile(request) 

185 collection = get_object_or_404( 

186 RecipeCollection, id=collection_id, profile=profile 

187 ) 

188 

189 try: 

190 collection.name = payload.name 

191 collection.description = payload.description 

192 collection.save() 

193 return collection 

194 except IntegrityError: 

195 return 400, {'detail': 'A collection with this name already exists'} 

196 

197 

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

199def delete_collection(request, collection_id: int): 

200 """Delete a collection.""" 

201 profile = get_current_profile(request) 

202 collection = get_object_or_404( 

203 RecipeCollection, id=collection_id, profile=profile 

204 ) 

205 collection.delete() 

206 return 204, None 

207 

208 

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

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

211 """Add a recipe to a collection.""" 

212 profile = get_current_profile(request) 

213 collection = get_object_or_404( 

214 RecipeCollection, id=collection_id, profile=profile 

215 ) 

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

217 

218 # Get the next order value 

219 max_order = collection.items.aggregate(max_order=Max('order'))['max_order'] 

220 next_order = (max_order or 0) + 1 

221 

222 try: 

223 item = RecipeCollectionItem.objects.create( 

224 collection=collection, 

225 recipe=recipe, 

226 order=next_order, 

227 ) 

228 return 201, item 

229 except IntegrityError: 

230 return 400, {'detail': 'Recipe is already in this collection'} 

231 

232 

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

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

235 """Remove a recipe from a collection.""" 

236 profile = get_current_profile(request) 

237 collection = get_object_or_404( 

238 RecipeCollection, id=collection_id, profile=profile 

239 ) 

240 item = get_object_or_404( 

241 RecipeCollectionItem, collection=collection, recipe_id=recipe_id 

242 ) 

243 item.delete() 

244 return 204, None 

245 

246 

247# ============================================================================= 

248# History Router 

249# ============================================================================= 

250 

251history_router = Router(tags=['history']) 

252 

253 

254class HistoryIn(Schema): 

255 recipe_id: int 

256 

257 

258class HistoryOut(Schema): 

259 recipe: RecipeListOut 

260 viewed_at: str 

261 

262 @staticmethod 

263 def resolve_viewed_at(obj): 

264 return obj.viewed_at.isoformat() 

265 

266 

267@history_router.get('/', response=List[HistoryOut]) 

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

269 """ 

270 Get recently viewed recipes for the current profile. 

271 

272 Returns up to 6 most recent recipes by default. 

273 """ 

274 profile = get_current_profile(request) 

275 return ( 

276 RecipeViewHistory.objects.filter(profile=profile) 

277 .select_related('recipe')[:limit] 

278 ) 

279 

280 

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

282def record_view(request, payload: HistoryIn): 

283 """ 

284 Record a recipe view. 

285 

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

287 """ 

288 profile = get_current_profile(request) 

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

290 

291 history, created = RecipeViewHistory.objects.update_or_create( 

292 profile=profile, 

293 recipe=recipe, 

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

295 ) 

296 

297 status = 201 if created else 200 

298 return status, history 

299 

300 

301@history_router.delete('/', response={204: None}) 

302def clear_history(request): 

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

304 profile = get_current_profile(request) 

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

306 return 204, None 

← Back to Dashboard