Coverage for apps / ai / api_scaling.py: 68%
60 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"""AI recipe scaling API endpoints."""
3import logging
4from typing import List, Optional
6from django_ratelimit.decorators import ratelimit
7from ninja import Router, Schema, Status
9from apps.core.auth import SessionAuth
10from apps.recipes.models import Recipe
12from .api import ErrorOut, handle_ai_errors
13from .services.quota import release_quota, reserve_quota
14from .services.scaling import scale_recipe, calculate_nutrition
16security_logger = logging.getLogger("security")
18router = Router(tags=["ai"])
21# Schemas
24class ScaleIn(Schema):
25 recipe_id: int
26 target_servings: int
27 unit_system: str = "metric"
28 profile_id: int
31class NutritionOut(Schema):
32 per_serving: dict
33 total: dict
36class ScaleOut(Schema):
37 target_servings: int
38 original_servings: int
39 ingredients: List[str]
40 instructions: List[str] = [] # QA-031
41 notes: List[str]
42 prep_time_adjusted: Optional[int] = None # QA-032
43 cook_time_adjusted: Optional[int] = None # QA-032
44 total_time_adjusted: Optional[int] = None # QA-032
45 nutrition: Optional[NutritionOut] = None
46 cached: bool
49# Endpoints
52@router.post(
53 "/scale", response={200: ScaleOut, 400: ErrorOut, 404: ErrorOut, 429: dict, 503: ErrorOut}, auth=SessionAuth()
54)
55@ratelimit(key="ip", rate="30/h", method="POST", block=False)
56@handle_ai_errors
57def scale_recipe_endpoint(request, data: ScaleIn):
58 """Scale a recipe to a different number of servings.
60 Only works for recipes owned by the requesting profile.
61 """
62 if getattr(request, "limited", False):
63 security_logger.warning("Rate limit hit: /ai/scale from %s", request.META.get("REMOTE_ADDR"))
64 return Status(429, {"error": "rate_limited", "message": "Too many requests. Please try again later."})
66 allowed, info = reserve_quota(request.auth, "scale")
67 if not allowed:
68 return Status(429, {"error": "quota_exceeded", "message": "Daily limit reached for scale", **info})
70 from apps.profiles.utils import get_current_profile_or_none
72 profile = get_current_profile_or_none(request)
74 if not profile:
75 release_quota(request.auth, "scale")
76 return Status(
77 404,
78 {
79 "error": "not_found",
80 "message": "Profile not found",
81 },
82 )
84 # Verify the profile_id in the request matches the session profile
85 if data.profile_id != profile.id:
86 release_quota(request.auth, "scale")
87 return Status(
88 404,
89 {
90 "error": "not_found",
91 "message": f"Profile {data.profile_id} not found",
92 },
93 )
95 try:
96 recipe = Recipe.objects.get(id=data.recipe_id)
97 except Recipe.DoesNotExist:
98 release_quota(request.auth, "scale")
99 return Status(
100 404,
101 {
102 "error": "not_found",
103 "message": f"Recipe {data.recipe_id} not found",
104 },
105 )
107 if recipe.profile_id != profile.id:
108 release_quota(request.auth, "scale")
109 return Status(
110 404,
111 {
112 "error": "not_found",
113 "message": f"Recipe {data.recipe_id} not found",
114 },
115 )
117 try:
118 result = scale_recipe(
119 recipe_id=data.recipe_id,
120 target_servings=data.target_servings,
121 profile=profile,
122 unit_system=data.unit_system,
123 )
124 except ValueError as e:
125 release_quota(request.auth, "scale")
126 return Status(
127 400,
128 {
129 "error": "validation_error",
130 "message": str(e),
131 },
132 )
133 except Exception:
134 release_quota(request.auth, "scale")
135 raise
137 # Calculate nutrition if available
138 nutrition = None
139 if recipe.nutrition:
140 nutrition = calculate_nutrition(
141 recipe=recipe,
142 original_servings=recipe.servings,
143 target_servings=data.target_servings,
144 )
146 if result.get("cached"):
147 release_quota(request.auth, "scale")
148 return {
149 "target_servings": result["target_servings"],
150 "original_servings": result["original_servings"],
151 "ingredients": result["ingredients"],
152 "instructions": result.get("instructions", []), # QA-031
153 "notes": result["notes"],
154 "prep_time_adjusted": result.get("prep_time_adjusted"), # QA-032
155 "cook_time_adjusted": result.get("cook_time_adjusted"), # QA-032
156 "total_time_adjusted": result.get("total_time_adjusted"), # QA-032
157 "nutrition": nutrition,
158 "cached": result["cached"],
159 }