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

142 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-02 13:22 +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 

49 # Admin UI is available in home mode only. Passkey mode users are peers; 

50 # admin work is CLI-only (see spec 014-remove-is-staff, FR-005/FR-006). 

51 request.is_admin = django_settings.AUTH_MODE == "home" 

52 

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

54 

55 return wrapper 

56 

57 

58def require_admin(view_func): 

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

60 

61 @wraps(view_func) 

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

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

64 return redirect("legacy:home") 

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

66 

67 return wrapper 

68 

69 

70def _is_ai_available() -> bool: 

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

72 settings = AppSettings.get() 

73 if not settings.openrouter_api_key: 

74 return False 

75 is_valid, _ = OpenRouterService.validate_key_cached() 

76 return is_valid 

77 

78 

79def device_pair(request): 

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

81 if django_settings.AUTH_MODE != "passkey": 

82 return redirect("legacy:profile_selector") 

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

84 

85 

86def profile_selector(request): 

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

88 if django_settings.AUTH_MODE == "passkey": 

89 return redirect("legacy:device_pair") 

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

91 return render( 

92 request, 

93 "legacy/profile_selector.html", 

94 { 

95 "profiles": profiles, 

96 }, 

97 ) 

98 

99 

100@require_profile 

101def home(request): 

102 """Home screen.""" 

103 profile = request.profile 

104 

105 # Get favorites for this profile 

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

107 favorites_count = favorites_qs.count() 

108 favorites = favorites_qs[:12] 

109 

110 # Get recently viewed for this profile 

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

112 history = history_qs[:6] 

113 

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

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

116 

117 # Build favorite recipe IDs set for checking 

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

119 

120 # Check if AI features are available 

121 ai_available = _is_ai_available() 

122 

123 return render( 

124 request, 

125 "legacy/home.html", 

126 { 

127 "profile": { 

128 "id": profile.id, 

129 "name": profile.name, 

130 "avatar_color": profile.avatar_color, 

131 }, 

132 "favorites": favorites, 

133 "favorites_count": favorites_count, 

134 "history": history, 

135 "recipes_count": recipes_count, 

136 "favorite_recipe_ids": favorite_recipe_ids, 

137 "ai_available": ai_available, 

138 }, 

139 ) 

140 

141 

142@require_profile 

143def search(request): 

144 """Search results screen.""" 

145 profile = request.profile 

146 

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

148 

149 return render( 

150 request, 

151 "legacy/search.html", 

152 { 

153 "profile": { 

154 "id": profile.id, 

155 "name": profile.name, 

156 "avatar_color": profile.avatar_color, 

157 }, 

158 "query": query, 

159 }, 

160 ) 

161 

162 

163@require_profile 

164def recipe_detail(request, recipe_id): 

165 """Recipe detail screen.""" 

166 profile = request.profile 

167 

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

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

170 

171 # Record view history 

172 RecipeViewHistory.objects.update_or_create( 

173 profile=profile, 

174 recipe=recipe, 

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

176 ) 

177 

178 # Check if recipe is favorited 

179 is_favorite = RecipeFavorite.objects.filter( 

180 profile=profile, 

181 recipe=recipe, 

182 ).exists() 

183 

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

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

186 

187 # Check if AI features are available 

188 ai_available = _is_ai_available() 

189 

190 # Prepare ingredient groups or flat list 

191 has_ingredient_groups = bool(recipe.ingredient_groups) 

192 

193 # Prepare instructions 

194 instructions = recipe.instructions 

195 if not instructions and recipe.instructions_text: 

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

197 

198 # Build linked recipes for navigation 

199 linked_recipes = [] 

200 if recipe.remixed_from_id: 

201 # This is a remix - link to original 

202 original = recipe.remixed_from 

203 if original: 

204 linked_recipes.append( 

205 { 

206 "id": original.id, 

207 "title": original.title, 

208 "relationship": "original", 

209 } 

210 ) 

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

212 siblings = Recipe.objects.filter( 

213 remixed_from=original, 

214 profile=profile, 

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

216 for sibling in siblings: 

217 linked_recipes.append( 

218 { 

219 "id": sibling.id, 

220 "title": sibling.title, 

221 "relationship": "sibling", 

222 } 

223 ) 

224 else: 

225 # This is an original - find its remixes 

226 remixes = Recipe.objects.filter( 

227 remixed_from=recipe, 

228 profile=profile, 

229 )[:5] 

230 for remix in remixes: 

231 linked_recipes.append( 

232 { 

233 "id": remix.id, 

234 "title": remix.title, 

235 "relationship": "remix", 

236 } 

237 ) 

238 

239 return render( 

240 request, 

241 "legacy/recipe_detail.html", 

242 { 

243 "profile": { 

244 "id": profile.id, 

245 "name": profile.name, 

246 "avatar_color": profile.avatar_color, 

247 }, 

248 "recipe": recipe, 

249 "is_favorite": is_favorite, 

250 "collections": collections, 

251 "ai_available": ai_available, 

252 "has_ingredient_groups": has_ingredient_groups, 

253 "instructions": instructions, 

254 "linked_recipes": linked_recipes, 

255 }, 

256 ) 

257 

258 

259@require_profile 

260def play_mode(request, recipe_id): 

261 """Play mode / cooking mode screen.""" 

262 profile = request.profile 

263 

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

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

266 

267 # Check if AI features are available 

268 ai_available = _is_ai_available() 

269 

270 # Prepare instructions 

271 instructions = recipe.instructions 

272 if not instructions and recipe.instructions_text: 

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

274 

275 return render( 

276 request, 

277 "legacy/play_mode.html", 

278 { 

279 "profile": { 

280 "id": profile.id, 

281 "name": profile.name, 

282 "avatar_color": profile.avatar_color, 

283 }, 

284 "recipe": recipe, 

285 "instructions": instructions, 

286 "instructions_json": instructions, # For JavaScript 

287 "ai_available": ai_available, 

288 }, 

289 ) 

290 

291 

292@require_profile 

293def all_recipes(request): 

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

295 profile = request.profile 

296 

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

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

299 

300 # Build set of favorite recipe IDs for display 

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

302 

303 return render( 

304 request, 

305 "legacy/all_recipes.html", 

306 { 

307 "profile": { 

308 "id": profile.id, 

309 "name": profile.name, 

310 "avatar_color": profile.avatar_color, 

311 }, 

312 "recipes": recipes, 

313 "favorite_recipe_ids": favorite_recipe_ids, 

314 }, 

315 ) 

316 

317 

318@require_profile 

319def favorites(request): 

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

321 profile = request.profile 

322 

323 # Get all favorites for this profile 

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

325 

326 return render( 

327 request, 

328 "legacy/favorites.html", 

329 { 

330 "profile": { 

331 "id": profile.id, 

332 "name": profile.name, 

333 "avatar_color": profile.avatar_color, 

334 }, 

335 "favorites": favorites, 

336 }, 

337 ) 

338 

339 

340@require_profile 

341def collections(request): 

342 """Collections list screen.""" 

343 profile = request.profile 

344 

345 # Get all collections for this profile 

346 collections = ( 

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

348 ) 

349 

350 return render( 

351 request, 

352 "legacy/collections.html", 

353 { 

354 "profile": { 

355 "id": profile.id, 

356 "name": profile.name, 

357 "avatar_color": profile.avatar_color, 

358 }, 

359 "collections": collections, 

360 }, 

361 ) 

362 

363 

364@require_profile 

365def collection_detail(request, collection_id): 

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

367 profile = request.profile 

368 

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

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

371 

372 # Get all items in this collection 

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

374 

375 # Build set of favorite recipe IDs for display 

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

377 

378 return render( 

379 request, 

380 "legacy/collection_detail.html", 

381 { 

382 "profile": { 

383 "id": profile.id, 

384 "name": profile.name, 

385 "avatar_color": profile.avatar_color, 

386 }, 

387 "collection": collection, 

388 "items": items, 

389 "favorite_recipe_ids": favorite_recipe_ids, 

390 }, 

391 ) 

392 

393 

394@require_profile 

395def settings(request): 

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

397 profile = request.profile 

398 

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

400 

401 # Only load admin-specific data for admin users 

402 if is_admin: 

403 app_settings = AppSettings.get() 

404 ai_available = _is_ai_available() 

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

406 try: 

407 service = OpenRouterService() 

408 models = service.get_available_models() 

409 except (AIUnavailableError, AIResponseError): 

410 models = [] 

411 else: 

412 ai_available = False 

413 prompts = [] 

414 models = [] 

415 app_settings = None 

416 

417 return render( 

418 request, 

419 "legacy/settings.html", 

420 { 

421 "profile": { 

422 "id": profile.id, 

423 "name": profile.name, 

424 "avatar_color": profile.avatar_color, 

425 "theme": profile.theme, 

426 "unit_preference": profile.unit_preference, 

427 }, 

428 "current_profile_id": profile.id, 

429 "ai_available": ai_available, 

430 "is_admin": is_admin, 

431 "auth_mode": django_settings.AUTH_MODE, 

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

433 "prompts": prompts, 

434 "models": models, 

435 }, 

436 ) 

← Back to Dashboard