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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""
2User feature API endpoints: favorites, collections, history.
4All endpoints are scoped to the current profile (from session).
5"""
7from typing import List, Optional
9from django.db import IntegrityError
10from django.db.models import Max
11from django.shortcuts import get_object_or_404
12from ninja import Router, Schema
14from apps.profiles.utils import get_current_profile
16from .api import RecipeListOut
17from .models import (
18 Recipe,
19 RecipeCollection,
20 RecipeCollectionItem,
21 RecipeFavorite,
22 RecipeViewHistory,
23)
25# =============================================================================
26# Favorites Router
27# =============================================================================
29favorites_router = Router(tags=['favorites'])
32class FavoriteIn(Schema):
33 recipe_id: int
36class FavoriteOut(Schema):
37 recipe: RecipeListOut
38 created_at: str
40 @staticmethod
41 def resolve_created_at(obj):
42 return obj.created_at.isoformat()
45class ErrorOut(Schema):
46 detail: str
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')
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)
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'}
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
80# =============================================================================
81# Collections Router
82# =============================================================================
84collections_router = Router(tags=['collections'])
87class CollectionIn(Schema):
88 name: str
89 description: str = ''
92class CollectionItemIn(Schema):
93 recipe_id: int
96class CollectionItemOut(Schema):
97 recipe: RecipeListOut
98 order: int
99 added_at: str
101 @staticmethod
102 def resolve_added_at(obj):
103 return obj.added_at.isoformat()
106class CollectionOut(Schema):
107 id: int
108 name: str
109 description: str
110 recipe_count: int
111 created_at: str
112 updated_at: str
114 @staticmethod
115 def resolve_recipe_count(obj):
116 return obj.items.count()
118 @staticmethod
119 def resolve_created_at(obj):
120 return obj.created_at.isoformat()
122 @staticmethod
123 def resolve_updated_at(obj):
124 return obj.updated_at.isoformat()
127class CollectionDetailOut(Schema):
128 id: int
129 name: str
130 description: str
131 recipes: List[CollectionItemOut]
132 created_at: str
133 updated_at: str
135 @staticmethod
136 def resolve_recipes(obj):
137 return obj.items.select_related('recipe').all()
139 @staticmethod
140 def resolve_created_at(obj):
141 return obj.created_at.isoformat()
143 @staticmethod
144 def resolve_updated_at(obj):
145 return obj.updated_at.isoformat()
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')
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)
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'}
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
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 )
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'}
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
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)
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
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'}
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
247# =============================================================================
248# History Router
249# =============================================================================
251history_router = Router(tags=['history'])
254class HistoryIn(Schema):
255 recipe_id: int
258class HistoryOut(Schema):
259 recipe: RecipeListOut
260 viewed_at: str
262 @staticmethod
263 def resolve_viewed_at(obj):
264 return obj.viewed_at.isoformat()
267@history_router.get('/', response=List[HistoryOut])
268def list_history(request, limit: int = 6):
269 """
270 Get recently viewed recipes for the current profile.
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 )
281@history_router.post('/', response={200: HistoryOut, 201: HistoryOut, 404: ErrorOut})
282def record_view(request, payload: HistoryIn):
283 """
284 Record a recipe view.
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)
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 )
297 status = 201 if created else 200
298 return status, history
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