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

1"""AI remix API endpoints.""" 

2 

3import logging 

4from typing import List, Optional 

5 

6from django_ratelimit.decorators import ratelimit 

7from ninja import Router, Schema, Status 

8 

9from apps.core.auth import SessionAuth 

10from apps.recipes.models import Recipe 

11 

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 

16 

17security_logger = logging.getLogger("security") 

18 

19router = Router(tags=["ai"]) 

20 

21 

22# Schemas 

23 

24 

25class RemixSuggestionsIn(Schema): 

26 recipe_id: int 

27 

28 

29class RemixSuggestionsOut(Schema): 

30 suggestions: List[str] 

31 

32 

33class CreateRemixIn(Schema): 

34 recipe_id: int 

35 modification: str 

36 profile_id: int 

37 

38 

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 

53 

54 

55# Endpoints 

56 

57 

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. 

67 

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

73 

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

77 

78 from apps.profiles.utils import get_current_profile_or_none 

79 

80 profile = get_current_profile_or_none(request) 

81 

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 ) 

93 

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 ) 

103 

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} 

113 

114 

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. 

122 

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

129 

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

133 

134 from apps.profiles.utils import get_current_profile_or_none 

135 

136 profile = get_current_profile_or_none(request) 

137 

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 ) 

147 

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 ) 

158 

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 ) 

170 

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 ) 

180 

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 } 

← Back to Dashboard