Coverage for apps / legacy / views.py: 96%

115 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 19:13 +0000

1"""Views for legacy frontend.""" 

2 

3from functools import wraps 

4 

5from django.shortcuts import render, redirect, get_object_or_404 

6 

7from apps.core.models import AppSettings 

8from apps.profiles.models import Profile 

9from apps.ai.models import AIPrompt 

10from apps.ai.services.openrouter import OpenRouterService, AIUnavailableError, AIResponseError 

11from apps.recipes.models import ( 

12 Recipe, 

13 RecipeCollection, 

14 RecipeFavorite, 

15 RecipeViewHistory, 

16) 

17 

18 

19def require_profile(view_func): 

20 """Decorator to ensure a profile is selected and valid. 

21 

22 Gets profile_id from session, validates it exists, and adds 

23 the Profile instance to request.profile. 

24 

25 Redirects to profile_selector if: 

26 - No profile_id in session 

27 - Profile doesn't exist (also clears session) 

28 """ 

29 

30 @wraps(view_func) 

31 def wrapper(request, *args, **kwargs): 

32 profile_id = request.session.get("profile_id") 

33 if not profile_id: 

34 return redirect("legacy:profile_selector") 

35 

36 try: 

37 request.profile = Profile.objects.get(id=profile_id) 

38 except Profile.DoesNotExist: 

39 del request.session["profile_id"] 

40 return redirect("legacy:profile_selector") 

41 

42 return view_func(request, *args, **kwargs) 

43 

44 return wrapper 

45 

46 

47def _is_ai_available() -> bool: 

48 """Check if AI features are available (key configured AND valid).""" 

49 settings = AppSettings.get() 

50 if not settings.openrouter_api_key: 

51 return False 

52 is_valid, _ = OpenRouterService.validate_key_cached() 

53 return is_valid 

54 

55 

56def profile_selector(request): 

57 """Profile selector screen.""" 

58 profiles = list(Profile.objects.all().values("id", "name", "avatar_color", "theme", "unit_preference")) 

59 return render( 

60 request, 

61 "legacy/profile_selector.html", 

62 { 

63 "profiles": profiles, 

64 }, 

65 ) 

66 

67 

68@require_profile 

69def home(request): 

70 """Home screen.""" 

71 profile = request.profile 

72 

73 # Get favorites for this profile 

74 favorites_qs = RecipeFavorite.objects.filter(profile=profile).select_related("recipe").order_by("-created_at") 

75 favorites_count = favorites_qs.count() 

76 favorites = favorites_qs[:12] 

77 

78 # Get recently viewed for this profile 

79 history_qs = RecipeViewHistory.objects.filter(profile=profile).select_related("recipe").order_by("-viewed_at") 

80 history = history_qs[:6] 

81 

82 # Get total recipe count (for "My Recipes" link) 

83 recipes_count = Recipe.objects.filter(profile=profile).count() 

84 

85 # Build favorite recipe IDs set for checking 

86 favorite_recipe_ids = set(f.recipe_id for f in favorites) 

87 

88 # Check if AI features are available 

89 ai_available = _is_ai_available() 

90 

91 return render( 

92 request, 

93 "legacy/home.html", 

94 { 

95 "profile": { 

96 "id": profile.id, 

97 "name": profile.name, 

98 "avatar_color": profile.avatar_color, 

99 }, 

100 "favorites": favorites, 

101 "favorites_count": favorites_count, 

102 "history": history, 

103 "recipes_count": recipes_count, 

104 "favorite_recipe_ids": favorite_recipe_ids, 

105 "ai_available": ai_available, 

106 }, 

107 ) 

108 

109 

110@require_profile 

111def search(request): 

112 """Search results screen.""" 

113 profile = request.profile 

114 

115 query = request.GET.get("q", "") 

116 # Detect if query is a URL 

117 is_url = query.strip().startswith("http://") or query.strip().startswith("https://") 

118 

119 return render( 

120 request, 

121 "legacy/search.html", 

122 { 

123 "profile": { 

124 "id": profile.id, 

125 "name": profile.name, 

126 "avatar_color": profile.avatar_color, 

127 }, 

128 "query": query, 

129 "is_url": is_url, 

130 }, 

131 ) 

132 

133 

134@require_profile 

135def recipe_detail(request, recipe_id): 

136 """Recipe detail screen.""" 

137 profile = request.profile 

138 

139 # Get the recipe (must belong to this profile) 

140 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile) 

141 

142 # Record view history 

143 RecipeViewHistory.objects.update_or_create( 

144 profile=profile, 

145 recipe=recipe, 

146 defaults={}, # Just update viewed_at (auto_now) 

147 ) 

148 

149 # Check if recipe is favorited 

150 is_favorite = RecipeFavorite.objects.filter( 

151 profile=profile, 

152 recipe=recipe, 

153 ).exists() 

154 

155 # Get user's collections for the "add to collection" feature 

156 collections = RecipeCollection.objects.filter(profile=profile) 

157 

158 # Check if AI features are available 

159 ai_available = _is_ai_available() 

160 

161 # Prepare ingredient groups or flat list 

162 has_ingredient_groups = bool(recipe.ingredient_groups) 

163 

164 # Prepare instructions 

165 instructions = recipe.instructions 

166 if not instructions and recipe.instructions_text: 

167 instructions = [s.strip() for s in recipe.instructions_text.split("\n") if s.strip()] 

168 

169 # Build linked recipes for navigation 

170 linked_recipes = [] 

171 if recipe.remixed_from_id: 

172 # This is a remix - link to original 

173 original = recipe.remixed_from 

174 if original: 

175 linked_recipes.append( 

176 { 

177 "id": original.id, 

178 "title": original.title, 

179 "relationship": "original", 

180 } 

181 ) 

182 # Also find sibling remixes (other remixes of the same original) 

183 siblings = Recipe.objects.filter( 

184 remixed_from=original, 

185 profile=profile, 

186 ).exclude(id=recipe.id)[:5] 

187 for sibling in siblings: 

188 linked_recipes.append( 

189 { 

190 "id": sibling.id, 

191 "title": sibling.title, 

192 "relationship": "sibling", 

193 } 

194 ) 

195 else: 

196 # This is an original - find its remixes 

197 remixes = Recipe.objects.filter( 

198 remixed_from=recipe, 

199 profile=profile, 

200 )[:5] 

201 for remix in remixes: 

202 linked_recipes.append( 

203 { 

204 "id": remix.id, 

205 "title": remix.title, 

206 "relationship": "remix", 

207 } 

208 ) 

209 

210 return render( 

211 request, 

212 "legacy/recipe_detail.html", 

213 { 

214 "profile": { 

215 "id": profile.id, 

216 "name": profile.name, 

217 "avatar_color": profile.avatar_color, 

218 }, 

219 "recipe": recipe, 

220 "is_favorite": is_favorite, 

221 "collections": collections, 

222 "ai_available": ai_available, 

223 "has_ingredient_groups": has_ingredient_groups, 

224 "instructions": instructions, 

225 "linked_recipes": linked_recipes, 

226 }, 

227 ) 

228 

229 

230@require_profile 

231def play_mode(request, recipe_id): 

232 """Play mode / cooking mode screen.""" 

233 profile = request.profile 

234 

235 # Get the recipe (must belong to this profile) 

236 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile) 

237 

238 # Check if AI features are available 

239 ai_available = _is_ai_available() 

240 

241 # Prepare instructions 

242 instructions = recipe.instructions 

243 if not instructions and recipe.instructions_text: 

244 instructions = [s.strip() for s in recipe.instructions_text.split("\n") if s.strip()] 

245 

246 return render( 

247 request, 

248 "legacy/play_mode.html", 

249 { 

250 "profile": { 

251 "id": profile.id, 

252 "name": profile.name, 

253 "avatar_color": profile.avatar_color, 

254 }, 

255 "recipe": recipe, 

256 "instructions": instructions, 

257 "instructions_json": instructions, # For JavaScript 

258 "ai_available": ai_available, 

259 }, 

260 ) 

261 

262 

263@require_profile 

264def all_recipes(request): 

265 """My Recipes screen - shows all recipes owned by this profile.""" 

266 profile = request.profile 

267 

268 # Get all recipes for this profile (imports + remixes) 

269 recipes = Recipe.objects.filter(profile=profile).order_by("-scraped_at") 

270 

271 # Build set of favorite recipe IDs for display 

272 favorite_recipe_ids = set(RecipeFavorite.objects.filter(profile=profile).values_list("recipe_id", flat=True)) 

273 

274 return render( 

275 request, 

276 "legacy/all_recipes.html", 

277 { 

278 "profile": { 

279 "id": profile.id, 

280 "name": profile.name, 

281 "avatar_color": profile.avatar_color, 

282 }, 

283 "recipes": recipes, 

284 "favorite_recipe_ids": favorite_recipe_ids, 

285 }, 

286 ) 

287 

288 

289@require_profile 

290def favorites(request): 

291 """Favorites screen - shows all favorited recipes.""" 

292 profile = request.profile 

293 

294 # Get all favorites for this profile 

295 favorites = RecipeFavorite.objects.filter(profile=profile).select_related("recipe").order_by("-created_at") 

296 

297 return render( 

298 request, 

299 "legacy/favorites.html", 

300 { 

301 "profile": { 

302 "id": profile.id, 

303 "name": profile.name, 

304 "avatar_color": profile.avatar_color, 

305 }, 

306 "favorites": favorites, 

307 }, 

308 ) 

309 

310 

311@require_profile 

312def collections(request): 

313 """Collections list screen.""" 

314 profile = request.profile 

315 

316 # Get all collections for this profile 

317 collections = ( 

318 RecipeCollection.objects.filter(profile=profile).prefetch_related("items__recipe").order_by("-updated_at") 

319 ) 

320 

321 return render( 

322 request, 

323 "legacy/collections.html", 

324 { 

325 "profile": { 

326 "id": profile.id, 

327 "name": profile.name, 

328 "avatar_color": profile.avatar_color, 

329 }, 

330 "collections": collections, 

331 }, 

332 ) 

333 

334 

335@require_profile 

336def collection_detail(request, collection_id): 

337 """Collection detail screen - shows recipes in a collection.""" 

338 profile = request.profile 

339 

340 # Get the collection (must belong to this profile) 

341 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile) 

342 

343 # Get all items in this collection 

344 items = collection.items.select_related("recipe").order_by("order", "-added_at") 

345 

346 # Build set of favorite recipe IDs for display 

347 favorite_recipe_ids = set(RecipeFavorite.objects.filter(profile=profile).values_list("recipe_id", flat=True)) 

348 

349 return render( 

350 request, 

351 "legacy/collection_detail.html", 

352 { 

353 "profile": { 

354 "id": profile.id, 

355 "name": profile.name, 

356 "avatar_color": profile.avatar_color, 

357 }, 

358 "collection": collection, 

359 "items": items, 

360 "favorite_recipe_ids": favorite_recipe_ids, 

361 }, 

362 ) 

363 

364 

365@require_profile 

366def settings(request): 

367 """Settings screen - AI prompts and sources configuration.""" 

368 profile = request.profile 

369 

370 # Get app settings 

371 app_settings = AppSettings.get() 

372 

373 # Check if AI features are available 

374 ai_available = _is_ai_available() 

375 

376 # Get all AI prompts 

377 prompts = list(AIPrompt.objects.all().order_by("name")) 

378 

379 # Get available models from OpenRouter 

380 try: 

381 service = OpenRouterService() 

382 models = service.get_available_models() 

383 except (AIUnavailableError, AIResponseError): 

384 models = [] 

385 

386 return render( 

387 request, 

388 "legacy/settings.html", 

389 { 

390 "profile": { 

391 "id": profile.id, 

392 "name": profile.name, 

393 "avatar_color": profile.avatar_color, 

394 }, 

395 "current_profile_id": profile.id, 

396 "ai_available": ai_available, 

397 "default_model": app_settings.default_ai_model, 

398 "prompts": prompts, 

399 "models": models, 

400 }, 

401 ) 

← Back to Dashboard