Coverage for admin_routes.py: 29%

403 statements  

« 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) 

20 

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 

30 

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 

35 

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" 

52 

53# Use shared templates path from app.settings to avoid path drift across modules. 

54templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) 

55 

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() 

63 

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}" 

72 

73 

74 

75 

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 

83 

84 for folder in sorted(DOWNLOADS_DIR.iterdir()): 

85 if not folder.is_dir(): 

86 continue 

87 

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 

99 

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 

110 

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 

115 

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 

122 

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) 

147 

148 return entries 

149 

150 

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} 

158 

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 

165 

166 min_ts = None 

167 max_ts = None 

168 total_rows = 0 

169 in_range = 0 

170 missing_files = 0 

171 

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 

193 

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 

198 

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 

204 

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) 

223 

224 return debug 

225 

226 

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 

239 

240 error_info: Optional[Dict[str, Any]] = None 

241 

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 } 

254 

255 if error_info: 

256 frame_debug = _segment_frame_debug(video_id, start, end) 

257 return {"error": error_info, "frame_debug": frame_debug} 

258 

259 return result_obj 

260 

261 

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}) 

269 

270 

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") 

280 

281 

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()} 

289 

290 

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 "") 

297 

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 

307 

308 

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") 

315 

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") 

322 

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)) 

332 

333 if not expert: 

334 raise HTTPException(status_code=404, detail="expert not found") 

335 return {"success": True, "expert": expert} 

336 

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} 

344 

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} 

358 

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} 

366 

367 

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) 

372 

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) 

381 

382 experts_with_videos = [ 

383 {**e, "claimed_videos": claimed_by_expert.get(e["expert_id"], [])} 

384 for e in list_experts() 

385 ] 

386 

387 return { 

388 "success": True, 

389 "experts": experts_with_videos, 

390 } 

391 

392 

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() 

399 

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'") 

406 

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") 

410 

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)) 

418 

419 return { 

420 "success": True, 

421 "video_id": video_id, 

422 "assigned_experts": list_experts_for_video(video_id), 

423 } 

424 

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 

429 

430 outcome = download_youtube(url) 

431 return outcome 

432 

433 

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) 

438 

439 

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 } 

456 

457 

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") 

468 

469 from datetime import datetime 

470 

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" 

474 

475 aggregated = { 

476 "video_id": video_id, 

477 "submitted_at": datetime.utcnow().isoformat(), 

478 "status": "submitted", 

479 "segments": questions_data, 

480 } 

481 

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}") 

488 

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 } 

495 

496#learner should only see video inherited from selected child's expert 

497 

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)) 

507 

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 } 

515 

516 

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() 

524 

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)) 

540 

541 

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") 

550 

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") 

563 

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)) 

580 

581 if not child: 

582 raise HTTPException(status_code=404, detail="child not found") 

583 return {"success": True, "child": child} 

584 

585 

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} 

592 

593 

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} 

600 

601 

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") 

618 

619 

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 ) 

626 

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 

637 

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 

649 

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 

656 

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 ) 

671 

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 

683 

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 

691 

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 ) 

701 

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 } 

709 

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 ) 

739 

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() 

749 

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 

758 

759 

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)) 

764 

765 

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})