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

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 AdminAuth, 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="5/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 parsed = urlparse(payload.url) 

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

223 except Exception: 

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

225 

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

232 

233 scraper = RecipeScraper() 

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

235 

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

246 

247 

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) 

254 

255 if cached_all is not None: 

256 return cached_all 

257 

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 

263 

264 

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 

271 

272 

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] 

284 

285 total = len(filtered) 

286 start = (page - 1) * per_page 

287 end = start + per_page 

288 

289 return { 

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

291 "total": total, 

292 "page": page, 

293 "has_more": end < total, 

294 "sites": sites, 

295 } 

296 

297 

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) 

303 

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) 

309 

310 for result in results: 

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

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

313 

314 

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. 

325 

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) 

330 

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

338 

339 source_list = None 

340 if sources: 

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

342 

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 

348 

349 

350@router.get("/cache/health/", response={200: dict}, auth=AdminAuth()) 

351def cache_health(request): 

352 """ 

353 Health check endpoint for image cache monitoring. 

354 

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 

360 

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

365 

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 } 

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

451 Delete a recipe by ID. 

452 

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

458 

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) 

← Back to Dashboard