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

1"""AI recipe scaling 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.quota import release_quota, reserve_quota 

14from .services.scaling import scale_recipe, calculate_nutrition 

15 

16security_logger = logging.getLogger("security") 

17 

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

19 

20 

21# Schemas 

22 

23 

24class ScaleIn(Schema): 

25 recipe_id: int 

26 target_servings: int 

27 unit_system: str = "metric" 

28 profile_id: int 

29 

30 

31class NutritionOut(Schema): 

32 per_serving: dict 

33 total: dict 

34 

35 

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 

47 

48 

49# Endpoints 

50 

51 

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. 

59 

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

65 

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

69 

70 from apps.profiles.utils import get_current_profile_or_none 

71 

72 profile = get_current_profile_or_none(request) 

73 

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 ) 

83 

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 ) 

94 

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 ) 

106 

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 ) 

116 

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 

136 

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 ) 

145 

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 } 

← Back to Dashboard