Coverage for admin_routes.py: 29%
403 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 20:58 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 20:58 -0400
1# admin_routes.py
2import json
3import csv
4import asyncio
5import sqlite3
6import os
7from typing import Any, Dict, List, Optional
8from app.services.frame_service import (
9 extract_frames_per_second_for_video as extract_frames_per_second_for_video_service,
10)
11from fastapi import (
12 APIRouter,
13 Body,
14 Form,
15 HTTPException,
16 Request,
17 WebSocket,
18 WebSocketDisconnect,
19)
21from app.services.children_service import(
22 ALLOWED_CHILD_ICON_KEYS,
23 create_child,
24 list_children,
25 update_child,
26 deactivate_child,
27 delete_child,
28)
29from app.services.report_service import get_child_report
31from fastapi.responses import HTMLResponse, JSONResponse
32from fastapi.templating import Jinja2Templates
33#Pulls shared path from settings.py so all module uses the same directory
34from app.settings import DOWNLOADS_DIR, TEMPLATES_DIR
36from app.services.expert_auth_service import (
37 add_video_assignment,
38 create_expert,
39 deactivate_expert,
40 list_experts,
41 delete_expert,
42 update_expert,
43 remove_video_assignment,
44 list_experts_for_video,
45)
46# ----- Local paths (keep consistent with main.py) -----
47#maybe needed if not delete later, should be duplicates with shared setting imports.
48#TODO- might delete this
49# BASE_DIR = Path(__file__).parent.resolve()
50# TEMPLATES_DIR = BASE_DIR / "templates"
51# DOWNLOADS_DIR = BASE_DIR / "downloads"
53# Use shared templates path from app.settings to avoid path drift across modules.
54templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
56# Three routers:
57# - pages: mounted under /admin
58# - api: mounted under /api
59# - ws: mounted with NO prefix (keeps /ws/... as-is)
60router_admin_pages = APIRouter()
61router_admin_api = APIRouter()
62router_admin_ws = APIRouter()
64# ===== Helpers duplicated here (tiny / no circulars) =====
65def format_hhmmss(total_seconds: int) -> str:
66 h = total_seconds // 3600
67 m = (total_seconds % 3600) // 60
68 s = total_seconds % 60
69 if h > 0:
70 return f"{h:02d}:{m:02d}:{s:02d}"
71 return f"{m:02d}:{s:02d}"
76def _collect_downloaded_videos(include_without_frames: bool = False) -> List[Dict[str, Any]]:
77 """
78 Enumerate downloads/<video_id> folders so the admin UI can reuse existing assets.
79 """
80 entries: List[Dict[str, Any]] = []
81 if not DOWNLOADS_DIR.exists():
82 return entries
84 for folder in sorted(DOWNLOADS_DIR.iterdir()):
85 if not folder.is_dir():
86 continue
88 video_id = folder.name
89 meta_path = folder / "meta.json"
90 title = video_id
91 duration_seconds: Optional[int] = None
92 if meta_path.exists():
93 try:
94 meta = json.loads(meta_path.read_text(encoding="utf-8"))
95 title = meta.get("title") or title
96 duration_seconds = meta.get("duration")
97 except Exception:
98 pass
100 frames_dir = folder / "extracted_frames"
101 frames_json = frames_dir / "frame_data.json"
102 has_frames = frames_json.exists()
103 frame_count = None
104 if frames_json.exists():
105 try:
106 frame_payload = json.loads(frames_json.read_text(encoding="utf-8"))
107 frame_count = frame_payload.get("video_info", {}).get("extracted_frames")
108 except Exception:
109 frame_count = None
111 questions_path = folder / "questions" / f"{video_id}.json"
112 has_questions = questions_path.exists()
113 if not include_without_frames and not has_frames:
114 continue
116 duration_label = None
117 if duration_seconds:
118 try:
119 duration_label = format_hhmmss(int(float(duration_seconds)))
120 except Exception:
121 duration_label = None
123 entry: Dict[str, Any] = {
124 "video_id": video_id,
125 "title": title,
126 "duration_seconds": duration_seconds,
127 "duration_formatted": duration_label,
128 "has_frames": has_frames,
129 "frame_count": frame_count,
130 "has_questions": has_questions,
131 }
132 if has_frames:
133 try:
134 entry["frames_dir"] = (
135 f"/downloads/{frames_dir.relative_to(DOWNLOADS_DIR).as_posix()}"
136 )
137 except ValueError:
138 entry["frames_dir"] = None
139 if has_questions:
140 try:
141 entry["question_file"] = (
142 f"/downloads/{questions_path.relative_to(DOWNLOADS_DIR).as_posix()}"
143 )
144 except ValueError:
145 entry["question_file"] = None
146 entries.append(entry)
148 return entries
151def _segment_frame_debug(video_id: str, start: int, end: int) -> Dict[str, Any]:
152 """
153 Inspect extracted frame coverage for a segment to explain generation failures.
154 """
155 frames_dir = DOWNLOADS_DIR / video_id / "extracted_frames"
156 csv_path = frames_dir / "frame_data.csv"
157 debug: Dict[str, Any] = {"video_id": video_id, "start": start, "end": end}
159 if not frames_dir.exists():
160 debug["reason"] = "frames_dir_missing"
161 return debug
162 if not csv_path.exists():
163 debug["reason"] = "frame_data_csv_missing"
164 return debug
166 min_ts = None
167 max_ts = None
168 total_rows = 0
169 in_range = 0
170 missing_files = 0
172 try:
173 with csv_path.open("r", encoding="utf-8") as handle:
174 reader = csv.DictReader(handle)
175 for row in reader:
176 total_rows += 1
177 ts_raw = row.get("Timestamp") or row.get("Time_Seconds") or row.get("Time_Formatted")
178 try:
179 if ts_raw is None:
180 continue
181 if isinstance(ts_raw, str) and ":" in ts_raw:
182 parts = [int(p) for p in ts_raw.split(":") if p.strip()]
183 if len(parts) == 3:
184 ts = parts[0] * 3600 + parts[1] * 60 + parts[2]
185 elif len(parts) == 2:
186 ts = parts[0] * 60 + parts[1]
187 else:
188 ts = int(parts[0])
189 else:
190 ts = int(float(ts_raw))
191 except Exception:
192 continue
194 if min_ts is None or ts < min_ts:
195 min_ts = ts
196 if max_ts is None or ts > max_ts:
197 max_ts = ts
199 if start <= ts <= end:
200 in_range += 1
201 filename = row.get("Filename") or ""
202 if filename and not (frames_dir / filename).exists():
203 missing_files += 1
205 debug.update(
206 {
207 "total_frames": total_rows,
208 "frames_in_range": in_range,
209 "min_timestamp": min_ts,
210 "max_timestamp": max_ts,
211 "missing_frame_files": missing_files,
212 }
213 )
214 if in_range == 0:
215 debug["reason"] = "no_frames_in_range"
216 elif missing_files > 0:
217 debug["reason"] = "missing_frame_files"
218 else:
219 debug["reason"] = "frames_present"
220 except Exception as exc:
221 debug["reason"] = "csv_parse_error"
222 debug["error"] = str(exc)
224 return debug
227def _wrap_segment_result(
228 video_id: str,
229 start: int,
230 end: int,
231 result_text: Optional[str],
232 result_obj: Any,
233) -> Any:
234 """
235 Normalize failed generations into a structured error payload.
236 """
237 if isinstance(result_obj, dict) and "error" in result_obj:
238 return result_obj
240 error_info: Optional[Dict[str, Any]] = None
242 if result_text is None:
243 error_info = {"reason": "generation_returned_none"}
244 elif isinstance(result_obj, str):
245 error_info = {
246 "reason": "invalid_json",
247 "raw_preview": result_obj[:300],
248 }
249 elif isinstance(result_obj, dict) and "questions" not in result_obj:
250 error_info = {
251 "reason": "missing_questions",
252 "keys": list(result_obj.keys()),
253 }
255 if error_info:
256 frame_debug = _segment_frame_debug(video_id, start, end)
257 return {"error": error_info, "frame_debug": frame_debug}
259 return result_obj
262# =========================================================
263# Admin page
264# =========================================================
265@router_admin_pages.get("/", response_class=HTMLResponse)
266def admin_page(request: Request):
267 # admin.html is self-contained (fetches data via JS), so no heavy context needed
268 return templates.TemplateResponse("admin.html", {"request": request})
271# =========================================================
272# Admin access code verification
273# =========================================================
274@router_admin_api.post("/admin/verify-access")
275async def verify_admin_access(payload: Dict[str, Any] = Body(...)):
276 expected = os.environ.get("ADMIN_PASSWORD", "admin123")
277 if payload.get("code") == expected:
278 return JSONResponse({"ok": True})
279 raise HTTPException(status_code=401, detail="Invalid access code")
282# =========================================================
283# Admin API
284# =========================================================
285# Admin loads all expert accounts for management UI.
286@router_admin_api.get("/admin/experts")
287def api_admin_list_experts():
288 return {"success": True, "experts": list_experts()}
291# Admin creates a new expert with ID + display name + password.
292@router_admin_api.post("/admin/experts")
293async def api_admin_create_expert(payload: Dict[str, Any] = Body(...)):
294 expert_id = str(payload.get("expert_id") or "").strip()
295 display_name = str(payload.get("display_name") or "").strip()
296 password = str(payload.get("password") or "")
298 try:
299 expert = create_expert(expert_id, display_name, password)
300 return {"success": True, "expert": expert}
301 except ValueError as exc:
302 raise HTTPException(status_code=400, detail=str(exc))
303 except RuntimeError as exc:
304 if str(exc) == "duplicate_expert_id":
305 raise HTTPException(status_code=409, detail="expert_id already exists")
306 raise
309# Admin updates fields selectively (rename, reset password, activate/deactivate).
310@router_admin_api.put("/admin/experts/{expert_id}")
311async def api_admin_update_expert(expert_id: str, payload: Dict[str, Any] = Body(...)):
312 display_name = payload.get("display_name")
313 password = payload.get("password")
314 is_active = payload.get("is_active")
316 if display_name is not None:
317 display_name = str(display_name)
318 if password is not None:
319 password = str(password)
320 if is_active is not None and not isinstance(is_active, bool):
321 raise HTTPException(status_code=400, detail="is_active must be true or false")
323 try:
324 expert = update_expert(
325 expert_id,
326 display_name=display_name,
327 password=password,
328 is_active=is_active,
329 )
330 except ValueError as exc:
331 raise HTTPException(status_code=400, detail=str(exc))
333 if not expert:
334 raise HTTPException(status_code=404, detail="expert not found")
335 return {"success": True, "expert": expert}
337# Explicit deactivate action for a simple one-click admin control.
338@router_admin_api.post("/admin/experts/{expert_id}/deactivate")
339async def api_admin_deactivate_expert(expert_id: str):
340 expert = deactivate_expert(expert_id)
341 if not expert:
342 raise HTTPException(status_code=404, detail="expert not found")
343 return {"success": True, "expert": expert}
345#delete
346@router_admin_api.delete("/admin/experts/{expert_id}")
347async def api_admin_delete_expert(expert_id: str):
348 try:
349 deleted = delete_expert(expert_id)
350 except sqlite3.IntegrityError:
351 raise HTTPException(
352 status_code=409,
353 detail="cannot delete expert while children are linked; reassign or deactivate children first",
354 )
355 if not deleted:
356 raise HTTPException(status_code=404, detail="expert not found")
357 return {"success": True}
359# Permanently delete a child from the database
360@router_admin_api.delete("/admin/children/{child_id}")
361async def api_admin_delete_child(child_id: str):
362 deleted = delete_child(child_id)
363 if not deleted:
364 raise HTTPException(status_code=404, detail="child not found")
365 return {"success": True}
368#Admin loads video + current expert assignment state for assignment UI bootstrap
369@router_admin_api.get("/admin/videos/assignments")
370def api_admin_list_video_assignments():
371 videos = _collect_downloaded_videos(include_without_frames=True)
373 # Build a map of expert_id -> list of claimed video titles
374 claimed_by_expert: Dict[str, List[str]] = {}
375 for video in videos:
376 assigned_experts = list_experts_for_video(video["video_id"])
377 title = video.get("title") or video["video_id"]
378 for e in assigned_experts:
379 eid = e["expert_id"]
380 claimed_by_expert.setdefault(eid, []).append(title)
382 experts_with_videos = [
383 {**e, "claimed_videos": claimed_by_expert.get(e["expert_id"], [])}
384 for e in list_experts()
385 ]
387 return {
388 "success": True,
389 "experts": experts_with_videos,
390 }
393# Admin adds or removes a single expert-video pair.
394@router_admin_api.post("/admin/videos/assignments")
395async def api_admin_set_video_assignment(payload: Dict[str, Any] = Body(...)):
396 video_id = str(payload.get("video_id") or "").strip()
397 expert_id = str(payload.get("expert_id") or "").strip()
398 op = str(payload.get("op") or "").strip()
400 if not video_id:
401 raise HTTPException(status_code=400, detail="video_id is required")
402 if not expert_id:
403 raise HTTPException(status_code=400, detail="expert_id is required")
404 if op not in {"add", "remove"}:
405 raise HTTPException(status_code=400, detail="op must be 'add' or 'remove'")
407 video_dir = DOWNLOADS_DIR / video_id
408 if not video_dir.exists() or not video_dir.is_dir():
409 raise HTTPException(status_code=404, detail="video not found in downloads")
411 try:
412 if op == "add":
413 add_video_assignment(video_id, expert_id, source="admin")
414 else:
415 remove_video_assignment(video_id, expert_id)
416 except ValueError as exc:
417 raise HTTPException(status_code=400, detail=str(exc))
419 return {
420 "success": True,
421 "video_id": video_id,
422 "assigned_experts": list_experts_for_video(video_id),
423 }
425@router_admin_api.post("/download")
426async def api_download(url: str = Form(...)):
427 # Lazy import to avoid circular dependency
428 from app.services.download_service import download_youtube
430 outcome = download_youtube(url)
431 return outcome
434@router_admin_api.post("/frames/{video_id}")
435async def api_extract_frames(video_id: str):
436 from app.services.frame_service import extract_frames_per_second_for_video
437 return extract_frames_per_second_for_video(video_id)
440@router_admin_api.get("/admin/videos")
441def admin_list_downloaded_videos(include_without_frames: bool = False):
442 """
443 Provide a lightweight manifest of downloaded videos so admins can reuse them.
444 """
445 videos = _collect_downloaded_videos(include_without_frames=include_without_frames)
446 return {
447 "success": True,
448 "count": len(videos),
449 "videos": videos,
450 "message": (
451 "Videos with extracted frames ready for question generation."
452 if not include_without_frames
453 else "All downloaded videos."
454 ),
455 }
458@router_admin_api.post("/submit-questions")
459async def submit_questions(payload: Dict[str, Any] = Body(...)):
460 """
461 Submit and save finalized questions (admin 'Submit' in UI).
462 Saves to downloads/<video_id>/questions/<video_id>.json
463 """
464 video_id = payload.get("video_id")
465 questions_data = payload.get("questions", [])
466 if not video_id or not questions_data:
467 raise HTTPException(status_code=400, detail="Missing video_id or questions")
469 from datetime import datetime
471 questions_dir = DOWNLOADS_DIR / video_id / "questions"
472 questions_dir.mkdir(parents=True, exist_ok=True)
473 out_path = questions_dir / f"{video_id}.json"
475 aggregated = {
476 "video_id": video_id,
477 "submitted_at": datetime.utcnow().isoformat(),
478 "status": "submitted",
479 "segments": questions_data,
480 }
482 try:
483 out_path.write_text(
484 json.dumps(aggregated, indent=2, ensure_ascii=False), encoding="utf-8"
485 )
486 except Exception as e:
487 raise HTTPException(status_code=500, detail=f"Failed to save: {e}")
489 return {
490 "success": True,
491 "message": "Questions submitted successfully",
492 "file_url": f"/downloads/{video_id}/questions/{out_path.name}",
493 "file_path": str(out_path),
494 }
496#learner should only see video inherited from selected child's expert
498@router_admin_api.get("/admin/children")
499def api_admin_list_children(
500 expert_id: Optional[str] = None,
501 include_inactive: bool = False,
502):
503 try:
504 children = list_children(expert_id=expert_id, include_inactive=include_inactive)
505 except ValueError as exc:
506 raise HTTPException(status_code=400, detail=str(exc))
508 return {
509 "success": True,
510 "children": children,
511 "experts": list_experts(), # for dropdown in admin UI
512 "icon_keys": list(ALLOWED_CHILD_ICON_KEYS),
513 "count": len(children),
514 }
517@router_admin_api.post("/admin/children")
518async def api_admin_create_child(payload: Dict[str, Any] = Body(...)):
519 expert_id = str(payload.get("expert_id") or "").strip()
520 first_name = str(payload.get("first_name") or "").strip()
521 last_name = str(payload.get("last_name") or "").strip()
522 icon_key = str(payload.get("icon_key") or "").strip().lower()
523 interaction_mode = str(payload.get("interaction_mode") or "flexible").strip().lower()
525 try:
526 child = create_child(
527 expert_id=expert_id,
528 first_name=first_name,
529 last_name=last_name,
530 icon_key=icon_key,
531 interaction_mode=interaction_mode,
532 )
533 return {"success": True, "child": child}
534 except ValueError as exc:
535 raise HTTPException(status_code=400, detail=str(exc))
536 except RuntimeError as exc:
537 if str(exc) == "duplicate_child_profile":
538 raise HTTPException(status_code=409, detail="duplicate child profile for this expert")
539 raise HTTPException(status_code=500, detail=str(exc))
542@router_admin_api.put("/admin/children/{child_id}")
543async def api_admin_update_child(child_id: str, payload: Dict[str, Any] = Body(...)):
544 expert_id = payload.get("expert_id")
545 first_name = payload.get("first_name")
546 last_name = payload.get("last_name")
547 icon_key = payload.get("icon_key")
548 interaction_mode = payload.get("interaction_mode")
549 is_active = payload.get("is_active")
551 if expert_id is not None:
552 expert_id = str(expert_id)
553 if first_name is not None:
554 first_name = str(first_name)
555 if last_name is not None:
556 last_name = str(last_name)
557 if interaction_mode is not None:
558 interaction_mode = str(interaction_mode).strip().lower()
559 if icon_key is not None:
560 icon_key = str(icon_key)
561 if is_active is not None and not isinstance(is_active, bool):
562 raise HTTPException(status_code=400, detail="is_active must be true or false")
564 try:
565 child = update_child(
566 child_id=child_id,
567 first_name=first_name,
568 last_name=last_name,
569 icon_key=icon_key,
570 interaction_mode=interaction_mode,
571 is_active=is_active,
572 expert_id=expert_id,
573 )
574 except ValueError as exc:
575 raise HTTPException(status_code=400, detail=str(exc))
576 except RuntimeError as exc:
577 if str(exc) == "duplicate_child_profile":
578 raise HTTPException(status_code=409, detail="duplicate child profile for this expert")
579 raise HTTPException(status_code=500, detail=str(exc))
581 if not child:
582 raise HTTPException(status_code=404, detail="child not found")
583 return {"success": True, "child": child}
586@router_admin_api.post("/admin/children/{child_id}/unlink")
587async def api_admin_unlink_child(child_id: str):
588 child = update_child(child_id=child_id, expert_id="")
589 if not child:
590 raise HTTPException(status_code=404, detail="child not found")
591 return {"success": True, "child": child}
594@router_admin_api.post("/admin/children/{child_id}/deactivate")
595async def api_admin_deactivate_child(child_id: str):
596 child = deactivate_child(child_id)
597 if not child:
598 raise HTTPException(status_code=404, detail="child not found")
599 return {"success": True, "child": child}
602# =========================================================
603# WebSocket – keep original path: /ws/questions/{video_id}
604# =========================================================
605@router_admin_ws.websocket("/ws/questions/{video_id}")
606async def ws_questions(websocket: WebSocket, video_id: str):
607 await websocket.accept()
608 try:
609 #recives the full JSON message from the browser
610 params = await websocket.receive_json()
611 #starting time.
612 start_seconds = int(params.get("start_seconds", 0))
613 interval_seconds = int(params.get("interval_seconds", 60))
614 #generate for the whole video.
615 full_duration = bool(params.get("full_duration", False))
616 #browser should send providers
617 provider = params.get("provider","openai")
620 # Lazy imports to avoid circulars
621 from app.services.question_generation_service import (
622 generate_questions_for_segment_with_retry,
623 build_segments_from_duration,
624 _maybe_parse_json,
625 )
627 frames_dir = DOWNLOADS_DIR / video_id / "extracted_frames"
628 if not frames_dir.exists():
629 await websocket.send_json(
630 {
631 "type": "error",
632 "message": "Frames not found. Please extract frames first.",
633 }
634 )
635 await websocket.close()
636 return
638 # Load duration if available
639 duration_seconds = None
640 json_path = frames_dir / "frame_data.json"
641 if json_path.exists():
642 try:
643 info = json.loads(json_path.read_text(encoding="utf-8"))
644 duration_seconds = int(
645 float(info.get("video_info", {}).get("duration_seconds", 0))
646 )
647 except Exception:
648 duration_seconds = None
650 # One-shot interval
651 if not full_duration:
652 start = max(0, int(start_seconds))
653 end = start + max(1, int(interval_seconds)) - 1
654 if duration_seconds is not None and end > duration_seconds:
655 end = duration_seconds
657 await websocket.send_json(
658 {
659 "type": "status",
660 "message": f"Generating questions for {start}-{end}s...",
661 }
662 )
663 #calls generate question, without freezing the server while it waits for the AI response.
664 result_text = await asyncio_to_thread(
665 generate_questions_for_segment_with_retry, video_id, start, end, provider = provider
666 )
667 result_obj = _maybe_parse_json(result_text)
668 result_obj = _wrap_segment_result(
669 video_id, start, end, result_text, result_obj
670 )
672 await websocket.send_json(
673 {
674 "type": "segment_result",
675 "start": start,
676 "end": end,
677 "result": result_obj,
678 }
679 )
680 await websocket.send_json({"type": "done", "auto_saved": False})
681 await websocket.close()
682 return
684 # Full loop
685 if duration_seconds is None or duration_seconds <= 0:
686 await websocket.send_json(
687 {"type": "error", "message": "Unable to determine video duration."}
688 )
689 await websocket.close()
690 return
692 segments = build_segments_from_duration(
693 duration_seconds, interval_seconds, start_seconds
694 )
695 await websocket.send_json(
696 {
697 "type": "status",
698 "message": f"Starting full-duration generation over {len(segments)} segments.",
699 }
700 )
702 aggregated = {
703 "video_id": video_id,
704 "interval_seconds": int(interval_seconds),
705 "start_offset": int(start_seconds),
706 "duration_seconds": duration_seconds,
707 "segments": [],
708 }
710 for idx, (seg_start, seg_end) in enumerate(segments, start=1):
711 await websocket.send_json(
712 {
713 "type": "status",
714 "message": f"[{idx}/{len(segments)}] {seg_start}-{seg_end}s",
715 }
716 )
717 result_text = await asyncio_to_thread(
718 generate_questions_for_segment_with_retry,
719 video_id,
720 seg_start,
721 seg_end,
722 provider = provider,
723 )
724 result_obj = _maybe_parse_json(result_text)
725 result_obj = _wrap_segment_result(
726 video_id, seg_start, seg_end, result_text, result_obj
727 )
728 aggregated["segments"].append(
729 {"start": seg_start, "end": seg_end, "result": result_obj}
730 )
731 await websocket.send_json(
732 {
733 "type": "segment_result",
734 "start": seg_start,
735 "end": seg_end,
736 "result": result_obj,
737 }
738 )
740 await websocket.send_json(
741 {
742 "type": "done",
743 "segments_count": len(segments),
744 "auto_saved": False,
745 "data": aggregated,
746 }
747 )
748 await websocket.close()
750 except WebSocketDisconnect:
751 pass
752 except Exception as e:
753 try:
754 await websocket.send_json({"type": "error", "message": str(e)})
755 await websocket.close()
756 except Exception:
757 pass
760# small helper: run sync function in thread (keeps this file standalone)
761def asyncio_to_thread(func, *args, **kwargs):
762 loop = asyncio.get_event_loop()
763 return loop.run_in_executor(None, lambda: func(*args, **kwargs))
766@router_admin_api.get("/reports/child/{child_id}")
767async def api_get_child_report(child_id: str):
768 """Return quiz score report for one child, used by parental reports tab."""
769 report = get_child_report(child_id)
770 return JSONResponse({"success": True, "report": report})