Coverage for apps / recipes / api_user.py: 94%
159 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +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, Status
14from django_ratelimit.decorators import ratelimit
16from apps.core.auth import SessionAuth
17from apps.profiles.utils import get_current_profile
19from .api import RecipeListOut
20from .models import (
21 Recipe,
22 RecipeCollection,
23 RecipeCollectionItem,
24 RecipeFavorite,
25 RecipeViewHistory,
26)
28# =============================================================================
29# Favorites Router
30# =============================================================================
32favorites_router = Router(tags=["favorites"])
35class FavoriteIn(Schema):
36 recipe_id: int
39class FavoriteOut(Schema):
40 recipe: RecipeListOut
41 created_at: str
43 @staticmethod
44 def resolve_created_at(obj):
45 return obj.created_at.isoformat()
48class ErrorOut(Schema):
49 detail: str
52@favorites_router.get("/", response=List[FavoriteOut], auth=SessionAuth())
53def list_favorites(request):
54 """List all favorites for the current profile."""
55 profile = get_current_profile(request)
56 return RecipeFavorite.objects.filter(profile=profile).select_related("recipe")
59@favorites_router.post(
60 "/", response={201: FavoriteOut, 400: ErrorOut, 404: ErrorOut, 429: ErrorOut}, auth=SessionAuth()
61)
62@ratelimit(key="ip", rate="60/h", method="POST", block=False)
63def add_favorite(request, payload: FavoriteIn):
64 """Add a recipe to favorites."""
65 if getattr(request, "limited", False):
66 return Status(429, {"detail": "Too many requests. Please try again later."})
67 profile = get_current_profile(request)
68 recipe = get_object_or_404(Recipe, id=payload.recipe_id)
70 try:
71 favorite = RecipeFavorite.objects.create(profile=profile, recipe=recipe)
72 return Status(201, favorite)
73 except IntegrityError:
74 return Status(400, {"detail": "Recipe is already a favorite"})
77@favorites_router.delete("/{recipe_id}/", response={204: None, 404: ErrorOut}, auth=SessionAuth())
78def remove_favorite(request, recipe_id: int):
79 """Remove a recipe from favorites."""
80 profile = get_current_profile(request)
81 favorite = get_object_or_404(RecipeFavorite, profile=profile, recipe_id=recipe_id)
82 favorite.delete()
83 return Status(204, None)
86# =============================================================================
87# Collections Router
88# =============================================================================
90collections_router = Router(tags=["collections"])
93class CollectionIn(Schema):
94 name: str
95 description: str = ""
98class CollectionItemIn(Schema):
99 recipe_id: int
102class CollectionItemOut(Schema):
103 recipe: RecipeListOut
104 order: int
105 added_at: str
107 @staticmethod
108 def resolve_added_at(obj):
109 return obj.added_at.isoformat()
112class CollectionOut(Schema):
113 id: int
114 name: str
115 description: str
116 recipe_count: int
117 cover_image: Optional[str]
118 created_at: str
119 updated_at: str
121 @staticmethod
122 def resolve_recipe_count(obj):
123 return obj.items.count()
125 @staticmethod
126 def resolve_cover_image(obj):
127 first_item = obj.items.select_related("recipe").first()
128 if first_item:
129 recipe = first_item.recipe
130 if recipe.image:
131 return recipe.image.url
132 if recipe.image_url:
133 return recipe.image_url
134 return None
136 @staticmethod
137 def resolve_created_at(obj):
138 return obj.created_at.isoformat()
140 @staticmethod
141 def resolve_updated_at(obj):
142 return obj.updated_at.isoformat()
145class CollectionDetailOut(Schema):
146 id: int
147 name: str
148 description: str
149 recipes: List[CollectionItemOut]
150 created_at: str
151 updated_at: str
153 @staticmethod
154 def resolve_recipes(obj):
155 return obj.items.select_related("recipe").all()
157 @staticmethod
158 def resolve_created_at(obj):
159 return obj.created_at.isoformat()
161 @staticmethod
162 def resolve_updated_at(obj):
163 return obj.updated_at.isoformat()
166@collections_router.get("/", response=List[CollectionOut], auth=SessionAuth())
167def list_collections(request):
168 """List all collections for the current profile."""
169 profile = get_current_profile(request)
170 return RecipeCollection.objects.filter(profile=profile).prefetch_related("items")
173@collections_router.post("/", response={201: CollectionOut, 400: ErrorOut, 429: ErrorOut}, auth=SessionAuth())
174@ratelimit(key="ip", rate="60/h", method="POST", block=False)
175def create_collection(request, payload: CollectionIn):
176 """Create a new collection."""
177 if getattr(request, "limited", False):
178 return Status(429, {"detail": "Too many requests. Please try again later."})
179 profile = get_current_profile(request)
181 try:
182 collection = RecipeCollection.objects.create(
183 profile=profile,
184 name=payload.name,
185 description=payload.description,
186 )
187 return Status(201, collection)
188 except IntegrityError:
189 return Status(400, {"detail": "A collection with this name already exists"})
192@collections_router.get("/{collection_id}/", response={200: CollectionDetailOut, 404: ErrorOut}, auth=SessionAuth())
193def get_collection(request, collection_id: int):
194 """Get a collection with its recipes."""
195 profile = get_current_profile(request)
196 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
197 return collection
200@collections_router.put(
201 "/{collection_id}/", response={200: CollectionOut, 400: ErrorOut, 404: ErrorOut}, auth=SessionAuth()
202)
203def update_collection(request, collection_id: int, payload: CollectionIn):
204 """Update a collection."""
205 profile = get_current_profile(request)
206 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
208 try:
209 collection.name = payload.name
210 collection.description = payload.description
211 collection.save()
212 return collection
213 except IntegrityError:
214 return Status(400, {"detail": "A collection with this name already exists"})
217@collections_router.delete("/{collection_id}/", response={204: None, 404: ErrorOut}, auth=SessionAuth())
218def delete_collection(request, collection_id: int):
219 """Delete a collection."""
220 profile = get_current_profile(request)
221 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
222 collection.delete()
223 return Status(204, None)
226@collections_router.post(
227 "/{collection_id}/recipes/", response={201: CollectionItemOut, 400: ErrorOut, 404: ErrorOut}, auth=SessionAuth()
228)
229def add_recipe_to_collection(request, collection_id: int, payload: CollectionItemIn):
230 """Add a recipe to a collection."""
231 profile = get_current_profile(request)
232 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
233 recipe = get_object_or_404(Recipe, id=payload.recipe_id)
235 # Get the next order value
236 max_order = collection.items.aggregate(max_order=Max("order"))["max_order"]
237 next_order = (max_order or 0) + 1
239 try:
240 item = RecipeCollectionItem.objects.create(
241 collection=collection,
242 recipe=recipe,
243 order=next_order,
244 )
245 return Status(201, item)
246 except IntegrityError:
247 return Status(400, {"detail": "Recipe is already in this collection"})
250@collections_router.delete(
251 "/{collection_id}/recipes/{recipe_id}/", response={204: None, 404: ErrorOut}, auth=SessionAuth()
252)
253def remove_recipe_from_collection(request, collection_id: int, recipe_id: int):
254 """Remove a recipe from a collection."""
255 profile = get_current_profile(request)
256 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
257 item = get_object_or_404(RecipeCollectionItem, collection=collection, recipe_id=recipe_id)
258 item.delete()
259 return Status(204, None)
262# =============================================================================
263# History Router
264# =============================================================================
266history_router = Router(tags=["history"])
269class HistoryIn(Schema):
270 recipe_id: int
273class HistoryOut(Schema):
274 recipe: RecipeListOut
275 viewed_at: str
277 @staticmethod
278 def resolve_viewed_at(obj):
279 return obj.viewed_at.isoformat()
282@history_router.get("/", response=List[HistoryOut], auth=SessionAuth())
283def list_history(request, limit: int = 6):
284 """
285 Get recently viewed recipes for the current profile.
287 Returns up to 6 most recent recipes by default (max 100).
288 """
289 profile = get_current_profile(request)
290 limit = min(max(limit, 1), 100)
291 return RecipeViewHistory.objects.filter(profile=profile).select_related("recipe")[:limit]
294@history_router.post("/", response={200: HistoryOut, 201: HistoryOut, 404: ErrorOut, 429: ErrorOut}, auth=SessionAuth())
295@ratelimit(key="ip", rate="120/h", method="POST", block=False)
296def record_view(request, payload: HistoryIn):
297 """
298 Record a recipe view.
300 If the recipe was already viewed, updates the timestamp.
301 """
302 if getattr(request, "limited", False):
303 return Status(429, {"detail": "Too many requests. Please try again later."})
304 profile = get_current_profile(request)
305 recipe = get_object_or_404(Recipe, id=payload.recipe_id)
307 history, created = RecipeViewHistory.objects.update_or_create(
308 profile=profile,
309 recipe=recipe,
310 defaults={}, # viewed_at auto-updates due to auto_now
311 )
313 status_code = 201 if created else 200
314 return Status(status_code, history)
317@history_router.delete("/", response={204: None}, auth=SessionAuth())
318def clear_history(request):
319 """Clear all view history for the current profile."""
320 profile = get_current_profile(request)
321 RecipeViewHistory.objects.filter(profile=profile).delete()
322 return Status(204, None)