Coverage for apps / recipes / api.py: 90%
180 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +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 HomeOnlyAuth, 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="100/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
222 parsed = urlparse(payload.url)
223 domain = (parsed.hostname or "").lower().removeprefix("www.")
224 except Exception:
225 return Status(400, {"detail": "Invalid URL"})
227 allowed_hosts = await sync_to_async(
228 lambda: set(SearchSource.objects.filter(is_enabled=True).values_list("host", flat=True))
229 )()
230 if domain not in allowed_hosts:
231 logger.warning(f"Scrape blocked: domain '{domain}' not in allowed sources")
232 return Status(400, {"detail": "URL domain is not a supported recipe source"})
234 scraper = RecipeScraper()
235 logger.info(f"Scrape request: {payload.url}")
237 try:
238 recipe = await scraper.scrape_url(payload.url, profile)
239 logger.info(f'Scrape success: {payload.url} -> recipe {recipe.id} "{recipe.title}"')
240 return Status(201, recipe)
241 except FetchError as e:
242 logger.warning(f"Scrape fetch error: {payload.url} - {e}")
243 return Status(502, {"detail": str(e)})
244 except ParseError as e:
245 logger.warning(f"Scrape parse error: {payload.url} - {e}")
246 return Status(400, {"detail": str(e)})
249async def _get_or_fetch_results(query: str) -> list:
250 """Return cached search results, or fetch and cache them."""
251 normalized_query = query.lower().strip()
252 query_hash = hashlib.sha256(normalized_query.encode()).hexdigest()[:16]
253 cache_key = f"search_{query_hash}"
254 cached_all = await sync_to_async(cache.get)(cache_key)
256 if cached_all is not None:
257 return cached_all
259 search = RecipeSearch()
260 full_results = await search.search(query=query, per_page=10000)
261 all_result_dicts = full_results.get("results", [])
262 await sync_to_async(cache.set)(cache_key, all_result_dicts, settings.SEARCH_CACHE_TIMEOUT)
263 return all_result_dicts
266def _aggregate_sites(result_dicts: list) -> dict:
267 """Count results per host from the full unfiltered result list."""
268 sites: dict[str, int] = {}
269 for r in result_dicts:
270 sites[r["host"]] = sites.get(r["host"], 0) + 1
271 return sites
274def _paginate_results(
275 all_results: list,
276 source_list: Optional[list],
277 sites: dict,
278 page: int,
279 per_page: int,
280) -> dict:
281 """Filter by sources, paginate, and return a SearchOut-shaped dict."""
282 filtered = all_results
283 if source_list:
284 filtered = [r for r in filtered if r["host"] in source_list]
286 total = len(filtered)
287 start = (page - 1) * per_page
288 end = start + per_page
290 return {
291 "results": filtered[start:end],
292 "total": total,
293 "page": page,
294 "has_more": end < total,
295 "sites": sites,
296 }
299async def _cache_and_map_images(results: list) -> None:
300 """Populate cached_image_url on each result dict, caching as needed."""
301 image_urls = [r["image_url"] for r in results if r.get("image_url")]
302 image_cache = SearchImageCache()
303 cached_urls = await image_cache.get_cached_urls_batch(image_urls)
305 uncached_urls = [url for url in image_urls if url not in cached_urls]
306 if uncached_urls:
307 await image_cache.cache_images(uncached_urls)
308 new_cached = await image_cache.get_cached_urls_batch(uncached_urls)
309 cached_urls.update(new_cached)
311 for result in results:
312 external_url = result.get("image_url", "")
313 result["cached_image_url"] = cached_urls.get(external_url)
316@router.get("/search/", response=SearchOut, auth=SessionAuth())
317async def search_recipes(
318 request,
319 q: str,
320 sources: Optional[str] = None,
321 page: int = 1,
322 per_page: int = 20,
323):
324 """
325 Search for recipes across multiple sites.
327 - **q**: Search query
328 - **sources**: Comma-separated list of hosts to search (optional)
329 - **page**: Page number (default 1)
330 - **per_page**: Results per page (default 20)
332 Returns recipe URLs from enabled search sources.
333 Uses cached images when available for iOS 9 compatibility.
334 Use the scrape endpoint to save a recipe from the results.
335 """
336 limited = await sync_to_async(is_ratelimited)(request, group="search", key="ip", rate="60/h", increment=True)
337 if limited:
338 raise HttpError(429, "Too many search requests. Please try again later.")
340 source_list = None
341 if sources:
342 source_list = [s.strip() for s in sources.split(",") if s.strip()]
344 all_result_dicts = await _get_or_fetch_results(q)
345 sites = _aggregate_sites(all_result_dicts)
346 results = _paginate_results(all_result_dicts, source_list, sites, page, per_page)
347 await _cache_and_map_images(results["results"])
348 return results
351def get_cache_health_dict() -> dict:
352 """Compute the image-cache health payload. Shared by the HTTP handler and the CLI."""
353 from apps.recipes.models import CachedSearchImage
355 total = CachedSearchImage.objects.count()
356 success = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_SUCCESS).count()
357 pending = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_PENDING).count()
358 failed = CachedSearchImage.objects.filter(status=CachedSearchImage.STATUS_FAILED).count()
360 return {
361 "status": "healthy",
362 "cache_stats": {
363 "total": total,
364 "success": success,
365 "pending": pending,
366 "failed": failed,
367 "success_rate": f"{(success / total * 100):.1f}%" if total > 0 else "N/A",
368 },
369 }
372@router.get("/cache/health/", response={200: dict}, auth=HomeOnlyAuth())
373def cache_health(request):
374 """Image-cache health check (home mode only; 404 in passkey mode via HomeOnlyAuth)."""
375 return get_cache_health_dict()
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 """Delete a recipe by ID. Only the owning profile can delete."""
451 recipe = get_object_or_404(Recipe, id=recipe_id, profile=request.auth)
452 recipe.delete()
453 return Status(204, None)