"""Question bank + custom-paper assembly (/api/exam/bank, /api/exam/custom-papers) — mode 3. Build-your-own-from-the-spec. The BANK is a browsable index over the leaf questions across the templates the caller can see (RLS-scoped, institute-wide); a CUSTOM PAPER is a normal exam_template whose questions are COPIES of the selected bank questions, so it flows through the existing setup / marking / projection pipeline unchanged. Copy-on-assemble (v1) avoids decoupling question identity from a template; a shared- question model is Phase-2. Design: ~/cc/ideas/2026-07-02-mode3-question-bank-design.md. All access is as-the-user (E1): the bank query returns only questions in templates RLS lets the caller see, and a custom paper is created owned by the caller + institute-scoped (writes pass the same RLS with-check). """ from __future__ import annotations import os import uuid from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from modules.database.services.exam_projection import project_template_safe from modules.logger_tool import initialise_logger from routers.exam.dependencies import ExamContext, get_exam_context logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) router = APIRouter() def _rows(result: Any) -> List[Dict[str, Any]]: data = getattr(result, "data", None) if not data: return [] return data if isinstance(data, list) else [data] def _first(result: Any) -> Optional[Dict[str, Any]]: rows = _rows(result) return rows[0] if rows else None @router.get("/bank") async def list_bank( spec_ref: Optional[str] = None, subject: Optional[str] = None, ctx: ExamContext = Depends(get_exam_context), ) -> Dict[str, Any]: """Leaf questions across the caller's institute templates, with their source-paper context. Filter by `spec_ref` (spec point) and/or `subject`. Facets over the full visible set drive the UI filters (so filtering by one axis doesn't hide the others' options). This is the spec-planning surface: a teacher picks a spec point → gets every question tagged it across their papers → assembles a paper. """ sel = ( "id, template_id, label, max_marks, answer_type, spec_ref, bounds, page, " "exam_templates(id, title, subject, exam_code)" ) query = ctx.supabase.table("exam_questions").select(sel).eq("is_container", False) if spec_ref: query = query.eq("spec_ref", spec_ref) rows = _rows(query.execute()) facets_spec: Dict[str, int] = {} facets_subject: Dict[str, int] = {} items: List[Dict[str, Any]] = [] for r in rows: tmpl = r.get("exam_templates") or {} subj = tmpl.get("subject") if r.get("spec_ref"): facets_spec[r["spec_ref"]] = facets_spec.get(r["spec_ref"], 0) + 1 if subj: facets_subject[subj] = facets_subject.get(subj, 0) + 1 if subject and subj != subject: continue items.append({ "id": r["id"], "template_id": r["template_id"], "label": r.get("label"), "max_marks": r.get("max_marks"), "answer_type": r.get("answer_type"), "spec_ref": r.get("spec_ref"), "bounds": r.get("bounds"), "page": r.get("page"), "paper": {"id": tmpl.get("id"), "title": tmpl.get("title"), "subject": subj, "exam_code": tmpl.get("exam_code")}, }) return {"questions": items, "n": len(items), "facets": {"spec_ref": facets_spec, "subject": facets_subject}} class CustomPaperRequest(BaseModel): title: str subject: Optional[str] = None institute_id: Optional[str] = None question_ids: List[str] @router.post("/custom-papers") async def create_custom_paper( body: CustomPaperRequest, ctx: ExamContext = Depends(get_exam_context), ) -> Dict[str, Any]: """Assemble the selected bank questions into a new template (copy-on-assemble), then project it.""" if not body.question_ids: raise HTTPException(status_code=400, detail="question_ids is required") institute_id = ctx.resolve_institute(body.institute_id) # RLS makes this return only leaf questions the caller may see; unknown/forbidden ids are silently dropped. srcs = _rows( ctx.supabase.table("exam_questions").select("*") .in_("id", body.question_ids).eq("is_container", False).execute() ) by_id = {s["id"]: s for s in srcs} ordered = [by_id[qid] for qid in body.question_ids if qid in by_id] # preserve the caller's order if not ordered: raise HTTPException(status_code=404, detail="No accessible questions for the given ids") template_id = str(uuid.uuid4()) ctx.supabase.table("exam_templates").insert({ "id": template_id, "title": body.title, "subject": body.subject, "institute_id": institute_id, "teacher_id": ctx.user_id, "status": "draft", }).execute() id_map: Dict[str, str] = {} q_rows: List[Dict[str, Any]] = [] for order, s in enumerate(ordered): new_id = str(uuid.uuid4()) id_map[s["id"]] = new_id q_rows.append({ "id": new_id, "template_id": template_id, "parent_id": None, "label": s.get("label"), "order": order, "max_marks": s.get("max_marks") or 0, "answer_type": s.get("answer_type"), "mcq_options": s.get("mcq_options"), "mark_scheme": s.get("mark_scheme") or {}, "is_container": False, "spec_ref": s.get("spec_ref"), "bounds": s.get("bounds"), "page": s.get("page"), "source": "manual", "confirmed": True, }) ctx.supabase.table("exam_questions").insert(q_rows).execute() # Copy each selected question's response areas onto its new copy (geometry travels with the question). ra_rows: List[Dict[str, Any]] = [] if id_map: for ra in _rows( ctx.supabase.table("exam_response_areas").select("*") .in_("question_id", list(id_map.keys())).execute() ): ra_rows.append({ "id": str(uuid.uuid4()), "question_id": id_map[ra["question_id"]], "template_id": template_id, "page": ra.get("page"), "bounds": ra.get("bounds"), "kind": ra.get("kind"), "response_form": ra.get("response_form"), "context_type": ra.get("context_type"), "source": "manual", "confirmed": True, }) if ra_rows: ctx.supabase.table("exam_response_areas").insert(ra_rows).execute() project_template_safe(template_id) # a custom paper is a normal paper in the graph return {"id": template_id, "title": body.title, "subject": body.subject, "n_questions": len(q_rows), "n_response_areas": len(ra_rows)}