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

143 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-12 10:49 +0000

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

2 

3from functools import wraps 

4 

5from django.conf import settings as django_settings 

6from django.shortcuts import render, redirect, get_object_or_404 

7 

8from apps.core.models import AppSettings 

9from apps.profiles.models import Profile 

10from apps.ai.models import AIPrompt 

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

12from apps.recipes.models import ( 

13 Recipe, 

14 RecipeCollection, 

15 RecipeFavorite, 

16 RecipeViewHistory, 

17) 

18 

19 

20def require_profile(view_func): 

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

22 

23 In passkey mode, redirects to device pairing instead of profile_selector. 

24 """ 

25 

26 @wraps(view_func) 

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

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

29 if django_settings.AUTH_MODE == "passkey": 

30 redirect_target = "legacy:device_pair" 

31 else: 

32 redirect_target = "legacy:profile_selector" 

33 

34 if not profile_id: 

35 return redirect(redirect_target) 

36 

37 try: 

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

39 except Profile.DoesNotExist: 

40 request.session.pop("profile_id", None) 

41 return redirect(redirect_target) 

42 

43 # In passkey mode, check that profile has a linked active user 

44 if django_settings.AUTH_MODE == "passkey": 

45 if not request.profile.user or not request.profile.user.is_active: 

46 request.session.pop("profile_id", None) 

47 return redirect(redirect_target) 

48 request.is_admin = request.profile.user.is_staff 

49 else: 

50 request.is_admin = True 

51 

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

53 

54 return wrapper 

55 

56 

57def require_admin(view_func): 

58 """Decorator for admin-only legacy views (passkey mode).""" 

59 

60 @wraps(view_func) 

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

62 if django_settings.AUTH_MODE == "passkey" and not getattr(request, "is_admin", False): 

63 return redirect("legacy:home") 

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

65 

66 return wrapper 

67 

68 

69def _is_ai_available() -> bool: 

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

71 settings = AppSettings.get() 

72 if not settings.openrouter_api_key: 

73 return False 

74 is_valid, _ = OpenRouterService.validate_key_cached() 

75 return is_valid 

76 

77 

78def device_pair(request): 

79 """Device pairing screen for passkey mode.""" 

80 if django_settings.AUTH_MODE != "passkey": 

81 return redirect("legacy:profile_selector") 

82 return render(request, "legacy/device_pair.html") 

83 

84 

85def profile_selector(request): 

86 """Profile selector screen. In passkey mode, redirects to device pairing.""" 

87 if django_settings.AUTH_MODE == "passkey": 

88 return redirect("legacy:device_pair") 

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

90 return render( 

91 request, 

92 "legacy/profile_selector.html", 

93 { 

94 "profiles": profiles, 

95 }, 

96 ) 

97 

98 

99@require_profile 

100def home(request): 

101 """Home screen.""" 

102 profile = request.profile 

103 

104 # Get favorites for this profile 

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

106 favorites_count = favorites_qs.count() 

107 favorites = favorites_qs[:12] 

108 

109 # Get recently viewed for this profile 

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

111 history = history_qs[:6] 

112 

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

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

115 

116 # Build favorite recipe IDs set for checking 

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

118 

119 # Check if AI features are available 

120 ai_available = _is_ai_available() 

121 

122 return render( 

123 request, 

124 "legacy/home.html", 

125 { 

126 "profile": { 

127 "id": profile.id, 

128 "name": profile.name, 

129 "avatar_color": profile.avatar_color, 

130 }, 

131 "favorites": favorites, 

132 "favorites_count": favorites_count, 

133 "history": history, 

134 "recipes_count": recipes_count, 

135 "favorite_recipe_ids": favorite_recipe_ids, 

136 "ai_available": ai_available, 

137 }, 

138 ) 

139 

140 

141@require_profile 

142def search(request): 

143 """Search results screen.""" 

144 profile = request.profile 

145 

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

147 

148 return render( 

149 request, 

150 "legacy/search.html", 

151 { 

152 "profile": { 

153 "id": profile.id, 

154 "name": profile.name, 

155 "avatar_color": profile.avatar_color, 

156 }, 

157 "query": query, 

158 }, 

159 ) 

160 

161 

162@require_profile 

163def recipe_detail(request, recipe_id): 

164 """Recipe detail screen.""" 

165 profile = request.profile 

166 

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

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

169 

170 # Record view history 

171 RecipeViewHistory.objects.update_or_create( 

172 profile=profile, 

173 recipe=recipe, 

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

175 ) 

176 

177 # Check if recipe is favorited 

178 is_favorite = RecipeFavorite.objects.filter( 

179 profile=profile, 

180 recipe=recipe, 

181 ).exists() 

182 

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

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

185 

186 # Check if AI features are available 

187 ai_available = _is_ai_available() 

188 

189 # Prepare ingredient groups or flat list 

190 has_ingredient_groups = bool(recipe.ingredient_groups) 

191 

192 # Prepare instructions 

193 instructions = recipe.instructions 

194 if not instructions and recipe.instructions_text: 

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

196 

197 # Build linked recipes for navigation 

198 linked_recipes = [] 

199 if recipe.remixed_from_id: 

200 # This is a remix - link to original 

201 original = recipe.remixed_from 

202 if original: 

203 linked_recipes.append( 

204 { 

205 "id": original.id, 

206 "title": original.title, 

207 "relationship": "original", 

208 } 

209 ) 

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

211 siblings = Recipe.objects.filter( 

212 remixed_from=original, 

213 profile=profile, 

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

215 for sibling in siblings: 

216 linked_recipes.append( 

217 { 

218 "id": sibling.id, 

219 "title": sibling.title, 

220 "relationship": "sibling", 

221 } 

222 ) 

223 else: 

224 # This is an original - find its remixes 

225 remixes = Recipe.objects.filter( 

226 remixed_from=recipe, 

227 profile=profile, 

228 )[:5] 

229 for remix in remixes: 

230 linked_recipes.append( 

231 { 

232 "id": remix.id, 

233 "title": remix.title, 

234 "relationship": "remix", 

235 } 

236 ) 

237 

238 return render( 

239 request, 

240 "legacy/recipe_detail.html", 

241 { 

242 "profile": { 

243 "id": profile.id, 

244 "name": profile.name, 

245 "avatar_color": profile.avatar_color, 

246 }, 

247 "recipe": recipe, 

248 "is_favorite": is_favorite, 

249 "collections": collections, 

250 "ai_available": ai_available, 

251 "has_ingredient_groups": has_ingredient_groups, 

252 "instructions": instructions, 

253 "linked_recipes": linked_recipes, 

254 }, 

255 ) 

256 

257 

258@require_profile 

259def play_mode(request, recipe_id): 

260 """Play mode / cooking mode screen.""" 

261 profile = request.profile 

262 

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

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

265 

266 # Check if AI features are available 

267 ai_available = _is_ai_available() 

268 

269 # Prepare instructions 

270 instructions = recipe.instructions 

271 if not instructions and recipe.instructions_text: 

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

273 

274 return render( 

275 request, 

276 "legacy/play_mode.html", 

277 { 

278 "profile": { 

279 "id": profile.id, 

280 "name": profile.name, 

281 "avatar_color": profile.avatar_color, 

282 }, 

283 "recipe": recipe, 

284 "instructions": instructions, 

285 "instructions_json": instructions, # For JavaScript 

286 "ai_available": ai_available, 

287 }, 

288 ) 

289 

290 

291@require_profile 

292def all_recipes(request): 

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

294 profile = request.profile 

295 

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

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

298 

299 # Build set of favorite recipe IDs for display 

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

301 

302 return render( 

303 request, 

304 "legacy/all_recipes.html", 

305 { 

306 "profile": { 

307 "id": profile.id, 

308 "name": profile.name, 

309 "avatar_color": profile.avatar_color, 

310 }, 

311 "recipes": recipes, 

312 "favorite_recipe_ids": favorite_recipe_ids, 

313 }, 

314 ) 

315 

316 

317@require_profile 

318def favorites(request): 

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

320 profile = request.profile 

321 

322 # Get all favorites for this profile 

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

324 

325 return render( 

326 request, 

327 "legacy/favorites.html", 

328 { 

329 "profile": { 

330 "id": profile.id, 

331 "name": profile.name, 

332 "avatar_color": profile.avatar_color, 

333 }, 

334 "favorites": favorites, 

335 }, 

336 ) 

337 

338 

339@require_profile 

340def collections(request): 

341 """Collections list screen.""" 

342 profile = request.profile 

343 

344 # Get all collections for this profile 

345 collections = ( 

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

347 ) 

348 

349 return render( 

350 request, 

351 "legacy/collections.html", 

352 { 

353 "profile": { 

354 "id": profile.id, 

355 "name": profile.name, 

356 "avatar_color": profile.avatar_color, 

357 }, 

358 "collections": collections, 

359 }, 

360 ) 

361 

362 

363@require_profile 

364def collection_detail(request, collection_id): 

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

366 profile = request.profile 

367 

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

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

370 

371 # Get all items in this collection 

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

373 

374 # Build set of favorite recipe IDs for display 

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

376 

377 return render( 

378 request, 

379 "legacy/collection_detail.html", 

380 { 

381 "profile": { 

382 "id": profile.id, 

383 "name": profile.name, 

384 "avatar_color": profile.avatar_color, 

385 }, 

386 "collection": collection, 

387 "items": items, 

388 "favorite_recipe_ids": favorite_recipe_ids, 

389 }, 

390 ) 

391 

392 

393@require_profile 

394def settings(request): 

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

396 profile = request.profile 

397 

398 is_admin = getattr(request, "is_admin", False) 

399 

400 # Only load admin-specific data for admin users 

401 if is_admin: 

402 app_settings = AppSettings.get() 

403 ai_available = _is_ai_available() 

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

405 try: 

406 service = OpenRouterService() 

407 models = service.get_available_models() 

408 except (AIUnavailableError, AIResponseError): 

409 models = [] 

410 else: 

411 ai_available = False 

412 prompts = [] 

413 models = [] 

414 app_settings = None 

415 

416 return render( 

417 request, 

418 "legacy/settings.html", 

419 { 

420 "profile": { 

421 "id": profile.id, 

422 "name": profile.name, 

423 "avatar_color": profile.avatar_color, 

424 "theme": profile.theme, 

425 "unit_preference": profile.unit_preference, 

426 }, 

427 "current_profile_id": profile.id, 

428 "ai_available": ai_available, 

429 "is_admin": is_admin, 

430 "auth_mode": django_settings.AUTH_MODE, 

431 "default_model": app_settings.default_ai_model if app_settings else "", 

432 "prompts": prompts, 

433 "models": models, 

434 }, 

435 ) 

← Back to Dashboard