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
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +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(RecipeFavorite, profile=profile, recipe_id=recipe_id)
74 favorite.delete()
75 return 204, None
78# =============================================================================
79# Collections Router
80# =============================================================================
82collections_router = Router(tags=["collections"])
85class CollectionIn(Schema):
86 name: str
87 description: str = ""
90class CollectionItemIn(Schema):
91 recipe_id: int
94class CollectionItemOut(Schema):
95 recipe: RecipeListOut
96 order: int
97 added_at: str
99 @staticmethod
100 def resolve_added_at(obj):
101 return obj.added_at.isoformat()
104class CollectionOut(Schema):
105 id: int
106 name: str
107 description: str
108 recipe_count: int
109 created_at: str
110 updated_at: str
112 @staticmethod
113 def resolve_recipe_count(obj):
114 return obj.items.count()
116 @staticmethod
117 def resolve_created_at(obj):
118 return obj.created_at.isoformat()
120 @staticmethod
121 def resolve_updated_at(obj):
122 return obj.updated_at.isoformat()
125class CollectionDetailOut(Schema):
126 id: int
127 name: str
128 description: str
129 recipes: List[CollectionItemOut]
130 created_at: str
131 updated_at: str
133 @staticmethod
134 def resolve_recipes(obj):
135 return obj.items.select_related("recipe").all()
137 @staticmethod
138 def resolve_created_at(obj):
139 return obj.created_at.isoformat()
141 @staticmethod
142 def resolve_updated_at(obj):
143 return obj.updated_at.isoformat()
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")
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)
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"}
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
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)
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"}
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
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)
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
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"}
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
233# =============================================================================
234# History Router
235# =============================================================================
237history_router = Router(tags=["history"])
240class HistoryIn(Schema):
241 recipe_id: int
244class HistoryOut(Schema):
245 recipe: RecipeListOut
246 viewed_at: str
248 @staticmethod
249 def resolve_viewed_at(obj):
250 return obj.viewed_at.isoformat()
253@history_router.get("/", response=List[HistoryOut])
254def list_history(request, limit: int = 6):
255 """
256 Get recently viewed recipes for the current profile.
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]
264@history_router.post("/", response={200: HistoryOut, 201: HistoryOut, 404: ErrorOut})
265def record_view(request, payload: HistoryIn):
266 """
267 Record a recipe view.
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)
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 )
280 status = 201 if created else 200
281 return status, history
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