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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""Views for legacy frontend."""
3from functools import wraps
5from django.conf import settings as django_settings
6from django.shortcuts import render, redirect, get_object_or_404
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)
20def require_profile(view_func):
21 """Decorator to ensure a profile is selected and valid.
23 In passkey mode, redirects to device pairing instead of profile_selector.
24 """
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"
34 if not profile_id:
35 return redirect(redirect_target)
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)
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
52 return view_func(request, *args, **kwargs)
54 return wrapper
57def require_admin(view_func):
58 """Decorator for admin-only legacy views (passkey mode)."""
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)
66 return wrapper
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
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")
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 )
99@require_profile
100def home(request):
101 """Home screen."""
102 profile = request.profile
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]
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]
113 # Get total recipe count (for "My Recipes" link)
114 recipes_count = Recipe.objects.filter(profile=profile).count()
116 # Build favorite recipe IDs set for checking
117 favorite_recipe_ids = set(f.recipe_id for f in favorites)
119 # Check if AI features are available
120 ai_available = _is_ai_available()
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 )
141@require_profile
142def search(request):
143 """Search results screen."""
144 profile = request.profile
146 query = request.GET.get("q", "")
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 )
162@require_profile
163def recipe_detail(request, recipe_id):
164 """Recipe detail screen."""
165 profile = request.profile
167 # Get the recipe (must belong to this profile)
168 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
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 )
177 # Check if recipe is favorited
178 is_favorite = RecipeFavorite.objects.filter(
179 profile=profile,
180 recipe=recipe,
181 ).exists()
183 # Get user's collections for the "add to collection" feature
184 collections = RecipeCollection.objects.filter(profile=profile)
186 # Check if AI features are available
187 ai_available = _is_ai_available()
189 # Prepare ingredient groups or flat list
190 has_ingredient_groups = bool(recipe.ingredient_groups)
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()]
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 )
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 )
258@require_profile
259def play_mode(request, recipe_id):
260 """Play mode / cooking mode screen."""
261 profile = request.profile
263 # Get the recipe (must belong to this profile)
264 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
266 # Check if AI features are available
267 ai_available = _is_ai_available()
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()]
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 )
291@require_profile
292def all_recipes(request):
293 """My Recipes screen - shows all recipes owned by this profile."""
294 profile = request.profile
296 # Get all recipes for this profile (imports + remixes)
297 recipes = Recipe.objects.filter(profile=profile).order_by("-scraped_at")
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))
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 )
317@require_profile
318def favorites(request):
319 """Favorites screen - shows all favorited recipes."""
320 profile = request.profile
322 # Get all favorites for this profile
323 favorites = RecipeFavorite.objects.filter(profile=profile).select_related("recipe").order_by("-created_at")
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 )
339@require_profile
340def collections(request):
341 """Collections list screen."""
342 profile = request.profile
344 # Get all collections for this profile
345 collections = (
346 RecipeCollection.objects.filter(profile=profile).prefetch_related("items__recipe").order_by("-updated_at")
347 )
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 )
363@require_profile
364def collection_detail(request, collection_id):
365 """Collection detail screen - shows recipes in a collection."""
366 profile = request.profile
368 # Get the collection (must belong to this profile)
369 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
371 # Get all items in this collection
372 items = collection.items.select_related("recipe").order_by("order", "-added_at")
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))
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 )
393@require_profile
394def settings(request):
395 """Settings screen - AI prompts and sources configuration."""
396 profile = request.profile
398 is_admin = getattr(request, "is_admin", False)
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
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 )