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
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +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)
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"
53 return view_func(request, *args, **kwargs)
55 return wrapper
58def require_admin(view_func):
59 """Decorator for admin-only legacy views (passkey mode)."""
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)
67 return wrapper
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
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")
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 )
100@require_profile
101def home(request):
102 """Home screen."""
103 profile = request.profile
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]
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]
114 # Get total recipe count (for "My Recipes" link)
115 recipes_count = Recipe.objects.filter(profile=profile).count()
117 # Build favorite recipe IDs set for checking
118 favorite_recipe_ids = set(f.recipe_id for f in favorites)
120 # Check if AI features are available
121 ai_available = _is_ai_available()
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 )
142@require_profile
143def search(request):
144 """Search results screen."""
145 profile = request.profile
147 query = request.GET.get("q", "")
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 )
163@require_profile
164def recipe_detail(request, recipe_id):
165 """Recipe detail screen."""
166 profile = request.profile
168 # Get the recipe (must belong to this profile)
169 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
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 )
178 # Check if recipe is favorited
179 is_favorite = RecipeFavorite.objects.filter(
180 profile=profile,
181 recipe=recipe,
182 ).exists()
184 # Get user's collections for the "add to collection" feature
185 collections = RecipeCollection.objects.filter(profile=profile)
187 # Check if AI features are available
188 ai_available = _is_ai_available()
190 # Prepare ingredient groups or flat list
191 has_ingredient_groups = bool(recipe.ingredient_groups)
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()]
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 )
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 )
259@require_profile
260def play_mode(request, recipe_id):
261 """Play mode / cooking mode screen."""
262 profile = request.profile
264 # Get the recipe (must belong to this profile)
265 recipe = get_object_or_404(Recipe, id=recipe_id, profile=profile)
267 # Check if AI features are available
268 ai_available = _is_ai_available()
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()]
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 )
292@require_profile
293def all_recipes(request):
294 """My Recipes screen - shows all recipes owned by this profile."""
295 profile = request.profile
297 # Get all recipes for this profile (imports + remixes)
298 recipes = Recipe.objects.filter(profile=profile).order_by("-scraped_at")
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))
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 )
318@require_profile
319def favorites(request):
320 """Favorites screen - shows all favorited recipes."""
321 profile = request.profile
323 # Get all favorites for this profile
324 favorites = RecipeFavorite.objects.filter(profile=profile).select_related("recipe").order_by("-created_at")
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 )
340@require_profile
341def collections(request):
342 """Collections list screen."""
343 profile = request.profile
345 # Get all collections for this profile
346 collections = (
347 RecipeCollection.objects.filter(profile=profile).prefetch_related("items__recipe").order_by("-updated_at")
348 )
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 )
364@require_profile
365def collection_detail(request, collection_id):
366 """Collection detail screen - shows recipes in a collection."""
367 profile = request.profile
369 # Get the collection (must belong to this profile)
370 collection = get_object_or_404(RecipeCollection, id=collection_id, profile=profile)
372 # Get all items in this collection
373 items = collection.items.select_related("recipe").order_by("order", "-added_at")
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))
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 )
394@require_profile
395def settings(request):
396 """Settings screen - AI prompts and sources configuration."""
397 profile = request.profile
399 is_admin = getattr(request, "is_admin", False)
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
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 )