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

1""" 

2Recipe API endpoints. 

3""" 

4 

5import hashlib 

6import logging 

7from typing import List, Optional 

8 

9logger = logging.getLogger(__name__) 

10 

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 

17 

18from django_ratelimit.core import is_ratelimited 

19 

20from apps.core.auth import HomeOnlyAuth, SessionAuth 

21from apps.profiles.utils import aget_current_profile_or_none, get_current_profile_or_none 

22 

23from .models import Recipe, SearchSource 

24from .services.image_cache import SearchImageCache 

25from .services.scraper import RecipeScraper, FetchError, ParseError 

26from .services.search import RecipeSearch 

27 

28router = Router(tags=["recipes"]) 

29 

30 

31# Schemas 

32 

33 

34class LinkedRecipeOut(Schema): 

35 """Minimal recipe info for linked recipe navigation.""" 

36 

37 id: int 

38 title: str 

39 relationship: str # "original", "remix", "sibling" 

40 

41 

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 

80 

81 @staticmethod 

82 def resolve_image(obj): 

83 if obj.image: 

84 return obj.image.url 

85 return None 

86 

87 @staticmethod 

88 def resolve_scraped_at(obj): 

89 return obj.scraped_at.isoformat() 

90 

91 @staticmethod 

92 def resolve_updated_at(obj): 

93 return obj.updated_at.isoformat() 

94 

95 @staticmethod 

96 def resolve_remixed_from_id(obj): 

97 return getattr(obj, "remixed_from_id", None) 

98 

99 @staticmethod 

100 def resolve_linked_recipes(obj): 

101 # Return linked_recipes if set, otherwise empty list 

102 return getattr(obj, "linked_recipes", []) 

103 

104 

105class RecipeListOut(Schema): 

106 """Condensed recipe output for list views.""" 

107 

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 

117 

118 @staticmethod 

119 def resolve_image(obj): 

120 if obj.image: 

121 return obj.image.url 

122 return None 

123 

124 @staticmethod 

125 def resolve_scraped_at(obj): 

126 return obj.scraped_at.isoformat() 

127 

128 

129class ScrapeIn(Schema): 

130 url: str 

131 

132 

133class ErrorOut(Schema): 

134 detail: str 

135 

136 

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 

145 

146 

147class SearchOut(Schema): 

148 results: List[SearchResultOut] 

149 total: int 

150 page: int 

151 has_more: bool 

152 sites: dict 

153 

154 

155# Endpoints 

156# NOTE: Static routes must come before dynamic routes (e.g., /search/ before /{recipe_id}/) 

157 

158 

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. 

169 

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 

174 

175 Returns only recipes owned by the current profile. 

176 """ 

177 profile = get_current_profile_or_none(request) 

178 if not profile: 

179 return [] 

180 

181 limit = min(max(limit, 1), 100) 

182 offset = max(offset, 0) 

183 

184 # Only show recipes owned by this profile 

185 qs = Recipe.objects.filter(profile=profile).order_by("-scraped_at") 

186 

187 if host: 

188 qs = qs.filter(host=host) 

189 if is_remix is not None: 

190 qs = qs.filter(is_remix=is_remix) 

191 

192 return qs[offset : offset + limit] 

193 

194 

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. 

203 

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. 

207 

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."}) 

213 

214 profile = await aget_current_profile_or_none(request) 

215 if not profile: 

216 return Status(403, {"detail": "Profile required to scrape recipes"}) 

217 

218 # Validate URL domain against enabled search sources 

219 try: 

220 from urllib.parse import urlparse 

221 

222 parsed = urlparse(payload.url) 

223 domain = (parsed.hostname or "").lower().removeprefix("www.") 

224 except Exception: 

225 return Status(400, {"detail": "Invalid URL"}) 

226 

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"}) 

233 

234 scraper = RecipeScraper() 

235 logger.info(f"Scrape request: {payload.url}") 

236 

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)}) 

247 

248 

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) 

255 

256 if cached_all is not None: 

257 return cached_all 

258 

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 

264 

265 

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 

272 

273 

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] 

285 

286 total = len(filtered) 

287 start = (page - 1) * per_page 

288 end = start + per_page 

289 

290 return { 

291 "results": filtered[start:end], 

292 "total": total, 

293 "page": page, 

294 "has_more": end < total, 

295 "sites": sites, 

296 } 

297 

298 

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) 

304 

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) 

310 

311 for result in results: 

312 external_url = result.get("image_url", "") 

313 result["cached_image_url"] = cached_urls.get(external_url) 

314 

315 

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. 

326 

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) 

331 

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.") 

339 

340 source_list = None 

341 if sources: 

342 source_list = [s.strip() for s in sources.split(",") if s.strip()] 

343 

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 

349 

350 

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 

354 

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() 

359 

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 } 

370 

371 

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() 

376 

377 

378# Dynamic routes with {recipe_id} must come last 

379 

380 

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. 

385 

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"}) 

392 

393 # Only allow access to recipes owned by this profile 

394 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile) 

395 

396 # Build linked recipes list for navigation 

397 linked_recipes = [] 

398 

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 ) 

427 

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 ) 

441 

442 # Attach linked recipes to the recipe object for serialization 

443 recipe.linked_recipes = linked_recipes 

444 

445 return recipe 

446 

447 

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) 

← Back to Dashboard