Coverage for apps / recipes / api.py: 87%
181 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"""
2Recipe API endpoints.
3"""
5import hashlib
6import logging
7from typing import List, Optional
9logger = logging.getLogger(__name__)
11from asgiref.sync import sync_to_async
12from django.conf import settings
13from django.core.cache import cache
14from django.shortcuts import get_object_or_404
15from ninja import Router, Schema, Status
16from ninja.errors import HttpError
18from django_ratelimit.core import is_ratelimited
20from apps.core.auth import AdminAuth, SessionAuth
21from apps.profiles.utils import aget_current_profile_or_none, get_current_profile_or_none
23from .models import Recipe, SearchSource
24from .services.image_cache import SearchImageCache
25from .services.scraper import RecipeScraper, FetchError, ParseError
26from .services.search import RecipeSearch
28router = Router(tags=["recipes"])
31# Schemas
34class LinkedRecipeOut(Schema):
35 """Minimal recipe info for linked recipe navigation."""
37 id: int
38 title: str
39 relationship: str # "original", "remix", "sibling"
42class RecipeOut(Schema):
43 id: int
44 source_url: Optional[str]
45 canonical_url: str
46 host: str
47 site_name: str
48 title: str
49 author: str
50 description: str
51 image_url: str
52 image: Optional[str] # Local image path
53 ingredients: list
54 ingredient_groups: list
55 instructions: list
56 instructions_text: str
57 prep_time: Optional[int]
58 cook_time: Optional[int]
59 total_time: Optional[int]
60 yields: str
61 servings: Optional[int]
62 category: str
63 cuisine: str
64 cooking_method: str
65 keywords: list
66 dietary_restrictions: list
67 equipment: list
68 nutrition: dict
69 rating: Optional[float]
70 rating_count: Optional[int]
71 language: str
72 links: list
73 ai_tips: list
74 is_remix: bool
75 remix_profile_id: Optional[int]
76 remixed_from_id: Optional[int]
77 linked_recipes: List[LinkedRecipeOut] = []
78 scraped_at: str
79 updated_at: str
81 @staticmethod
82 def resolve_image(obj):
83 if obj.image:
84 return obj.image.url
85 return None
87 @staticmethod
88 def resolve_scraped_at(obj):
89 return obj.scraped_at.isoformat()
91 @staticmethod
92 def resolve_updated_at(obj):
93 return obj.updated_at.isoformat()
95 @staticmethod
96 def resolve_remixed_from_id(obj):
97 return getattr(obj, "remixed_from_id", None)
99 @staticmethod
100 def resolve_linked_recipes(obj):
101 # Return linked_recipes if set, otherwise empty list
102 return getattr(obj, "linked_recipes", [])
105class RecipeListOut(Schema):
106 """Condensed recipe output for list views."""
108 id: int
109 title: str
110 host: str
111 image_url: str
112 image: Optional[str]
113 total_time: Optional[int]
114 rating: Optional[float]
115 is_remix: bool
116 scraped_at: str
118 @staticmethod
119 def resolve_image(obj):
120 if obj.image:
121 return obj.image.url
122 return None
124 @staticmethod
125 def resolve_scraped_at(obj):
126 return obj.scraped_at.isoformat()
129class ScrapeIn(Schema):
130 url: str
133class ErrorOut(Schema):
134 detail: str
137class SearchResultOut(Schema):
138 url: str
139 title: str
140 host: str
141 image_url: str # External URL (fallback)
142 cached_image_url: Optional[str] = None # Local cached URL
143 description: str
144 rating_count: Optional[int] = None
147class SearchOut(Schema):
148 results: List[SearchResultOut]
149 total: int
150 page: int
151 has_more: bool
152 sites: dict
155# Endpoints
156# NOTE: Static routes must come before dynamic routes (e.g., /search/ before /{recipe_id}/)
159@router.get("/", response=List[RecipeListOut], auth=SessionAuth())
160def list_recipes(
161 request,
162 host: Optional[str] = None,
163 is_remix: Optional[bool] = None,
164 limit: int = 50,
165 offset: int = 0,
166):
167 """
168 List saved recipes with optional filters.
170 - **host**: Filter by source host (e.g., "allrecipes.com")
171 - **is_remix**: Filter by remix status
172 - **limit**: Number of recipes to return (default 50)
173 - **offset**: Offset for pagination
175 Returns only recipes owned by the current profile.
176 """
177 profile = get_current_profile_or_none(request)
178 if not profile:
179 return []
181 limit = min(max(limit, 1), 100)
182 offset = max(offset, 0)
184 # Only show recipes owned by this profile
185 qs = Recipe.objects.filter(profile=profile).order_by("-scraped_at")
187 if host:
188 qs = qs.filter(host=host)
189 if is_remix is not None:
190 qs = qs.filter(is_remix=is_remix)
192 return qs[offset : offset + limit]
195@router.post(
196 "/scrape/",
197 response={201: RecipeOut, 400: ErrorOut, 403: ErrorOut, 429: ErrorOut, 502: ErrorOut},
198 auth=SessionAuth(),
199)
200async def scrape_recipe(request, payload: ScrapeIn):
201 """
202 Scrape a recipe from a URL.
204 The URL is fetched, parsed for recipe data, and saved to the database.
205 If the recipe has an image, it will be downloaded and stored locally.
206 The recipe will be owned by the current profile.
208 Note: Re-scraping the same URL will create a new recipe record.
209 """
210 limited = await sync_to_async(is_ratelimited)(request, group="scrape", key="ip", rate="5/h", increment=True)
211 if limited:
212 return Status(429, {"detail": "Too many scrape requests. Please try again later."})
214 profile = await aget_current_profile_or_none(request)
215 if not profile:
216 return Status(403, {"detail": "Profile required to scrape recipes"})
218 # Validate URL domain against enabled search sources
219 try:
220 from urllib.parse import urlparse
221 parsed = urlparse(payload.url)
222 domain = (parsed.hostname or "").lower().removeprefix("www.")
223 except Exception:
224 return Status(400, {"detail": "Invalid URL"})
226 allowed_hosts = await sync_to_async(
227 lambda: set(SearchSource.objects.filter(is_enabled=True).values_list("host", flat=True))
228 )()
229 if domain not in allowed_hosts:
230 logger.warning(f"Scrape blocked: domain '{domain}' not in allowed sources")
231 return Status(400, {"detail": "URL domain is not a supported recipe source"})
233 scraper = RecipeScraper()
234 logger.info(f"Scrape request: {payload.url}")
236 try:
237 recipe = await scraper.scrape_url(payload.url, profile)
238 logger.info(f'Scrape success: {payload.url} -> recipe {recipe.id} "{recipe.title}"')
239 return Status(201, recipe)
240 except FetchError as e:
241 logger.warning(f"Scrape fetch error: {payload.url} - {e}")
242 return Status(502, {"detail": str(e)})
243 except ParseError as e:
244 logger.warning(f"Scrape parse error: {payload.url} - {e}")
245 return Status(400, {"detail": str(e)})
248async def _get_or_fetch_results(query: str) -> list:
249 """Return cached search results, or fetch and cache them."""
250 normalized_query = query.lower().strip()
251 query_hash = hashlib.sha256(normalized_query.encode()).hexdigest()[:16]
252 cache_key = f"search_{query_hash}"
253 cached_all = await sync_to_async(cache.get)(cache_key)
255 if cached_all is not None:
256 return cached_all
258 search = RecipeSearch()
259 full_results = await search.search(query=query, per_page=10000)
260 all_result_dicts = full_results.get("results", [])
261 await sync_to_async(cache.set)(cache_key, all_result_dicts, settings.SEARCH_CACHE_TIMEOUT)
262 return all_result_dicts
265def _aggregate_sites(result_dicts: list) -> dict:
266 """Count results per host from the full unfiltered result list."""
267 sites: dict[str, int] = {}
268 for r in result_dicts:
269 sites[r["host"]] = sites.get(r["host"], 0) + 1
270 return sites
273def _paginate_results(
274 all_results: list,
275 source_list: Optional[list],
276 sites: dict,
277 page: int,
278 per_page: int,
279) -> dict:
280 """Filter by sources, paginate, and return a SearchOut-shaped dict."""
281 filtered = all_results
282 if source_list:
283 filtered = [r for r in filtered if r["host"] in source_list]
285 total = len(filtered)
286 start = (page - 1) * per_page
287 end = start + per_page
289 return {
290 "results": filtered[start:end],
291 "total": total,
292 "page": page,
293 "has_more": end < total,
294 "sites": sites,
295 }
298async def _cache_and_map_images(results: list) -> None:
299 """Populate cached_image_url on each result dict, caching as needed."""
300 image_urls = [r["image_url"] for r in results if r.get("image_url")]
301 image_cache = SearchImageCache()
302 cached_urls = await image_cache.get_cached_urls_batch(image_urls)
304 uncached_urls = [url for url in image_urls if url not in cached_urls]
305 if uncached_urls:
306 await image_cache.cache_images(uncached_urls)
307 new_cached = await image_cache.get_cached_urls_batch(uncached_urls)
308 cached_urls.update(new_cached)
310 for result in results:
311 external_url = result.get("image_url", "")
312 result["cached_image_url"] = cached_urls.get(external_url)
315@router.get("/search/", response=SearchOut)
316async def search_recipes(
317 request,
318 q: str,
319 sources: Optional[str] = None,
320 page: int = 1,
321 per_page: int = 20,
322):
323 """
324 Search for recipes across multiple sites.
326 - **q**: Search query
327 - **sources**: Comma-separated list of hosts to search (optional)
328 - **page**: Page number (default 1)
329 - **per_page**: Results per page (default 20)
331 Returns recipe URLs from enabled search sources.
332 Uses cached images when available for iOS 9 compatibility.
333 Use the scrape endpoint to save a recipe from the results.
334 """
335 limited = await sync_to_async(is_ratelimited)(request, group="search", key="ip", rate="60/h", increment=True)
336 if limited:
337 raise HttpError(429, "Too many search requests. Please try again later.")
339 source_list = None
340 if sources:
341 source_list = [s.strip() for s in sources.split(",") if s.strip()]
343 all_result_dicts = await _get_or_fetch_results(q)
344 sites = _aggregate_sites(all_result_dicts)
345 results = _paginate_results(all_result_dicts, source_list, sites, page, per_page)
346 await _cache_and_map_images(results["results"])
347 return results
350@router.get("/cache/health/", response={200: dict}, auth=AdminAuth())
351def cache_health(request):
352 """
353 Health check endpoint for image cache monitoring.
355 Returns cache statistics and status for monitoring the background
356 image caching system. Use this to verify caching is working correctly
357 and to track cache hit rates.
358 """
359 from apps.recipes.models import CachedSearchImage
361 total = CachedSearchImage.objects.count()
362 success = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_SUCCESS).count()
363 pending = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_PENDING).count()
364 failed = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_FAILED).count()
366 return {
367 "status": "healthy",
368 "cache_stats": {
369 "total": total,
370 "success": success,
371 "pending": pending,
372 "failed": failed,
373 "success_rate": f"{(success / total * 100):.1f}%" if total > 0 else "N/A",
374 },
375 }
378# Dynamic routes with {recipe_id} must come last
381@router.get("/{recipe_id}/", response={200: RecipeOut, 404: ErrorOut}, auth=SessionAuth())
382def get_recipe(request, recipe_id: int):
383 """
384 Get a recipe by ID.
386 Only returns recipes owned by the current profile.
387 Includes linked_recipes for navigation between original and remixes.
388 """
389 profile = get_current_profile_or_none(request)
390 if not profile:
391 return Status(404, {"detail": "Recipe not found"})
393 # Only allow access to recipes owned by this profile
394 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
396 # Build linked recipes list for navigation
397 linked_recipes = []
399 # Add original recipe if this is a remix
400 if recipe.remixed_from_id:
401 original = recipe.remixed_from
402 if original and original.profile_id == profile.id:
403 linked_recipes.append(
404 {
405 "id": original.id,
406 "title": original.title,
407 "relationship": "original",
408 }
409 )
410 # Add siblings (other remixes of the same original)
411 siblings = (
412 Recipe.objects.filter(
413 remixed_from=original,
414 profile=profile,
415 )
416 .exclude(id=recipe.id)
417 .values("id", "title")
418 )
419 for sibling in siblings:
420 linked_recipes.append(
421 {
422 "id": sibling["id"],
423 "title": sibling["title"],
424 "relationship": "sibling",
425 }
426 )
428 # Add children (remixes of this recipe)
429 children = Recipe.objects.filter(
430 remixed_from=recipe,
431 profile=profile,
432 ).values("id", "title")
433 for child in children:
434 linked_recipes.append(
435 {
436 "id": child["id"],
437 "title": child["title"],
438 "relationship": "remix",
439 }
440 )
442 # Attach linked recipes to the recipe object for serialization
443 recipe.linked_recipes = linked_recipes
445 return recipe
448@router.delete("/{recipe_id}/", response={204: None, 404: ErrorOut}, auth=SessionAuth())
449def delete_recipe(request, recipe_id: int):
450 """
451 Delete a recipe by ID.
453 Only the owning profile can delete a recipe.
454 """
455 profile = get_current_profile_or_none(request)
456 if not profile:
457 return Status(404, {"detail": "Recipe not found"})
459 # Only allow deletion of recipes owned by this profile
460 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
461 recipe.delete()
462 return Status(204, None)