Coverage for apps / ai / api_remix.py: 35%
82 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 remix 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.cache import is_ai_cache_hit
14from .services.quota import release_quota, reserve_quota
15from .services.remix import get_remix_suggestions, create_remix
17security_logger = logging.getLogger("security")
19router = Router(tags=["ai"])
22# Schemas
25class RemixSuggestionsIn(Schema):
26 recipe_id: int
29class RemixSuggestionsOut(Schema):
30 suggestions: List[str]
33class CreateRemixIn(Schema):
34 recipe_id: int
35 modification: str
36 profile_id: int
39class RemixOut(Schema):
40 id: int
41 title: str
42 description: str
43 ingredients: List[str]
44 instructions: List[str]
45 host: str
46 site_name: str
47 is_remix: bool
48 prep_time: Optional[int] = None
49 cook_time: Optional[int] = None
50 total_time: Optional[int] = None
51 yields: str = ""
52 servings: Optional[int] = None
55# Endpoints
58@router.post(
59 "/remix-suggestions",
60 response={200: RemixSuggestionsOut, 400: ErrorOut, 404: ErrorOut, 429: dict, 503: ErrorOut},
61 auth=SessionAuth(),
62)
63@ratelimit(key="ip", rate="30/h", method="POST", block=False)
64@handle_ai_errors
65def remix_suggestions(request, data: RemixSuggestionsIn):
66 """Get 6 AI-generated remix suggestions for a recipe.
68 Only works for recipes owned by the requesting profile.
69 """
70 if getattr(request, "limited", False):
71 security_logger.warning("Rate limit hit: /ai/remix-suggestions from %s", request.META.get("REMOTE_ADDR"))
72 return Status(429, {"error": "rate_limited", "message": "Too many requests. Please try again later."})
74 allowed, info = reserve_quota(request.auth, "remix_suggestions")
75 if not allowed:
76 return Status(429, {"error": "quota_exceeded", "message": "Daily limit reached for remix_suggestions", **info})
78 from apps.profiles.utils import get_current_profile_or_none
80 profile = get_current_profile_or_none(request)
82 try:
83 recipe = Recipe.objects.get(id=data.recipe_id)
84 except Recipe.DoesNotExist:
85 release_quota(request.auth, "remix_suggestions")
86 return Status(
87 404,
88 {
89 "error": "not_found",
90 "message": f"Recipe {data.recipe_id} not found",
91 },
92 )
94 if not profile or recipe.profile_id != profile.id:
95 release_quota(request.auth, "remix_suggestions")
96 return Status(
97 404,
98 {
99 "error": "not_found",
100 "message": f"Recipe {data.recipe_id} not found",
101 },
102 )
104 was_cached = is_ai_cache_hit("remix_suggestions", data.recipe_id)
105 try:
106 suggestions = get_remix_suggestions(data.recipe_id)
107 except Exception:
108 release_quota(request.auth, "remix_suggestions")
109 raise
110 if was_cached:
111 release_quota(request.auth, "remix_suggestions")
112 return {"suggestions": suggestions}
115@router.post(
116 "/remix", response={200: RemixOut, 400: ErrorOut, 404: ErrorOut, 429: dict, 503: ErrorOut}, auth=SessionAuth()
117)
118@ratelimit(key="ip", rate="10/h", method="POST", block=False)
119@handle_ai_errors
120def create_remix_endpoint(request, data: CreateRemixIn):
121 """Create a remixed recipe using AI.
123 Only works for recipes owned by the requesting profile.
124 The remix will be owned by the same profile.
125 """
126 if getattr(request, "limited", False):
127 security_logger.warning("Rate limit hit: /ai/remix from %s", request.META.get("REMOTE_ADDR"))
128 return Status(429, {"error": "rate_limited", "message": "Too many requests. Please try again later."})
130 allowed, info = reserve_quota(request.auth, "remix")
131 if not allowed:
132 return Status(429, {"error": "quota_exceeded", "message": "Daily limit reached for remix", **info})
134 from apps.profiles.utils import get_current_profile_or_none
136 profile = get_current_profile_or_none(request)
138 if not profile:
139 release_quota(request.auth, "remix")
140 return Status(
141 404,
142 {
143 "error": "not_found",
144 "message": "Profile not found",
145 },
146 )
148 # Verify the profile_id in the request matches the session profile
149 if data.profile_id != profile.id:
150 release_quota(request.auth, "remix")
151 return Status(
152 404,
153 {
154 "error": "not_found",
155 "message": f"Profile {data.profile_id} not found",
156 },
157 )
159 try:
160 recipe = Recipe.objects.get(id=data.recipe_id)
161 except Recipe.DoesNotExist:
162 release_quota(request.auth, "remix")
163 return Status(
164 404,
165 {
166 "error": "not_found",
167 "message": f"Recipe {data.recipe_id} not found",
168 },
169 )
171 if recipe.profile_id != profile.id:
172 release_quota(request.auth, "remix")
173 return Status(
174 404,
175 {
176 "error": "not_found",
177 "message": f"Recipe {data.recipe_id} not found",
178 },
179 )
181 try:
182 remix = create_remix(
183 recipe_id=data.recipe_id,
184 modification=data.modification,
185 profile=profile,
186 )
187 except Exception:
188 release_quota(request.auth, "remix")
189 raise
190 return {
191 "id": remix.id,
192 "title": remix.title,
193 "description": remix.description,
194 "ingredients": remix.ingredients,
195 "instructions": remix.instructions,
196 "host": remix.host,
197 "site_name": remix.site_name,
198 "is_remix": remix.is_remix,
199 "prep_time": remix.prep_time,
200 "cook_time": remix.cook_time,
201 "total_time": remix.total_time,
202 "yields": remix.yields,
203 "servings": remix.servings,
204 }