"""Study plans — ordered blocks of reading and questions a learner works through.""" import logging import re from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database import get_db from app.models.article import Article from app.models.exam import Exam from app.models.question import Question from app.models.study_plan import ( StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle, StudyPlanBlockProgress, ) from app.models.user import User from app.services import exam_blueprint from app.services.quiz_builder import (GenerateTestRequest, bank_query, create_saved_test, general_question_predicate) from app.utils.auth import get_current_user, require_moderator router = APIRouter() log = logging.getLogger(__name__) def _reading_for(db: Session, user: User, block_ids: list[int]) -> dict[int, list[dict]]: """Each block's reading, in order, with whether this learner has finished it.""" if not block_ids: return {} rows = db.query(StudyPlanBlockArticle, Article).join( Article, Article.id == StudyPlanBlockArticle.article_id).filter( StudyPlanBlockArticle.block_id.in_(block_ids)).order_by( StudyPlanBlockArticle.position, StudyPlanBlockArticle.id).all() read = {row[0] for row in db.query(StudyPlanArticleRead.block_article_id).filter( StudyPlanArticleRead.user_id == user.id).all()} out: dict[int, list[dict]] = {} for link, article in rows: # A draft is still listed for the educator who can open it, and left out # for everyone else rather than offered as a dead link. if article.status != "published" and not user.is_moderator and article.user_id != user.id: continue out.setdefault(link.block_id, []).append({ "link_id": link.id, "article_id": article.id, "slug": article.slug, "title": article.title, "summary": article.summary, "status": article.status, "read": link.id in read, }) return out @router.get("/") def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Published plans, with how far this learner has got through each.""" query = db.query(StudyPlan) if not current_user.is_moderator: query = query.filter(StudyPlan.is_published == 1) plans = query.order_by(StudyPlan.sort_order, StudyPlan.name).all() if not plans: return [] exams = {e.id: e.name for e in db.query(Exam).all()} done = { row[0] for row in db.query(StudyPlanBlockProgress.block_id).filter( StudyPlanBlockProgress.user_id == current_user.id, StudyPlanBlockProgress.completed_at.isnot(None)).all() } out = [] for plan in plans: blocks = plan.blocks out.append({ "id": plan.id, "slug": plan.slug, "name": plan.name, "description": plan.description, "kind": plan.kind, "exam_name": exams.get(plan.exam_id), "is_published": bool(plan.is_published), "block_count": len(blocks), "question_count": sum(len(b.question_ids or []) for b in blocks), "blocks_completed": sum(1 for b in blocks if b.id in done), }) return out @router.get("/{plan_id}") def get_study_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): plan = db.get(StudyPlan, plan_id) if not plan or (not plan.is_published and not current_user.is_moderator): raise HTTPException(404, "Study plan not found") progress = { row.block_id: row for row in db.query(StudyPlanBlockProgress).filter( StudyPlanBlockProgress.user_id == current_user.id).all() } reading = _reading_for(db, current_user, [block.id for block in plan.blocks]) return { "id": plan.id, "slug": plan.slug, "name": plan.name, "description": plan.description, "kind": plan.kind, "is_published": bool(plan.is_published), "blocks": [{ "id": block.id, "position": block.position, "title": block.title, "question_count": len(block.question_ids or []), "quiz_id": progress.get(block.id).quiz_id if block.id in progress else None, "completed": bool(progress.get(block.id) and progress[block.id].completed_at), # Reading first, then the questions it prepares you for. "articles": reading.get(block.id, []), } for block in plan.blocks], } @router.post("/blocks/{block_id}/start") def start_block(block_id: int, mode: str = "learning", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Turn a block into a test. Reuses the learner's existing quiz for the block. Without the reuse, reopening a block would create a duplicate test each time and scatter the learner's attempts across them. """ block = db.get(StudyPlanBlock, block_id) if not block: raise HTTPException(404, "Block not found") if mode not in ("learning", "timed"): raise HTTPException(400, "Mode must be learning or timed") row = db.query(StudyPlanBlockProgress).filter_by(block_id=block.id, user_id=current_user.id).first() if row and row.quiz_id: return {"id": row.quiz_id, "reused": True} # Only questions this learner may actually see. visible = { qid for (qid,) in bank_query(db, current_user) .with_entities(Question.id) .filter(Question.id.in_(block.question_ids or [])) .all() } ids = [qid for qid in (block.question_ids or []) if qid in visible] if not ids: raise HTTPException(400, "No questions in this block are available to you") plan = db.get(StudyPlan, block.plan_id) created = create_saved_test( db, current_user, GenerateTestRequest(title=f"{plan.name} — {block.title}", count=len(ids), mode=mode, time_limit_minutes=None, is_shared=False), ids, ) if row is None: row = StudyPlanBlockProgress(block_id=block.id, user_id=current_user.id) db.add(row) row.quiz_id = created["id"] db.commit() return {**created, "reused": False} @router.post("/reading/{link_id}/read") def mark_reading(link_id: int, read: bool = True, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Mark one of a block's articles as read, or take that back. Reversible on purpose: a learner who ticks the wrong row should be able to correct it without an educator, and progress nobody can correct stops being trusted and then stops being used. """ link = db.get(StudyPlanBlockArticle, link_id) if not link: raise HTTPException(404, "That reading is not part of a block") row = db.query(StudyPlanArticleRead).filter_by( block_article_id=link_id, user_id=current_user.id).first() if read and not row: db.add(StudyPlanArticleRead(block_article_id=link_id, user_id=current_user.id)) elif not read and row: db.delete(row) db.commit() return {"link_id": link_id, "read": read} # ── Editing, for moderators ─────────────────────────────────────────────────── SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") class PlanWrite(BaseModel): name: str = Field(min_length=1, max_length=200) slug: str = Field(min_length=1, max_length=120) description: str | None = None kind: str = "set" exam_id: int | None = None sort_order: int = 100 is_published: bool = True class PlanUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) description: str | None = None exam_id: int | None = None sort_order: int | None = None is_published: bool | None = None def _get_plan(db: Session, plan_id: int) -> StudyPlan: plan = db.get(StudyPlan, plan_id) if not plan: raise HTTPException(404, "Study plan not found") return plan def _get_block(db: Session, block_id: int) -> StudyPlanBlock: block = db.get(StudyPlanBlock, block_id) if not block: raise HTTPException(404, "Block not found") return block @router.post("/", status_code=201) def create_plan(data: PlanWrite, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): slug = data.slug.strip().lower() if not SLUG_RE.match(slug): raise HTTPException(400, "A slug is lowercase words joined by hyphens") if db.query(StudyPlan.id).filter(StudyPlan.slug == slug).first(): raise HTTPException(409, "A plan with that slug already exists") if data.kind not in ("set", "mixed"): raise HTTPException(400, "Kind must be set or mixed") plan = StudyPlan(slug=slug, name=data.name.strip(), description=data.description, kind=data.kind, exam_id=data.exam_id, sort_order=data.sort_order, is_published=1 if data.is_published else 0) db.add(plan) db.commit() return {"id": plan.id, "slug": plan.slug, "name": plan.name} class BlueprintPlan(BaseModel): exam_id: int slug: str name: str description: str | None = None #: "papers" — every block a miniature of the real exam, for rehearsal. #: "domains" — one block per content domain, for working through a subject. shape: str = "domains" block_size: int = Field(default=40, ge=5, le=200) #: How many blocks the plan has. For papers, how many to deal out; for #: domains, how they are shared out by weight — every domain still gets at #: least one, so nothing the board examines is left out. blocks: int = Field(default=24, ge=1, le=80) sort_order: int = 100 is_published: bool = True #: Attach the reading filed under each domain to its block. with_reading: bool = True @router.post("/from-blueprint", status_code=201) def create_from_blueprint(data: BlueprintPlan, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Build a plan to an examining board's published content outline. Two shapes, because a plan is asked to do two different things. Papers are rehearsal: each block is drawn to the board's weights, so sitting one tells a learner something about how they would do on the day. Domains are study: one block per content area, in the board's order and carrying its titles, which is how someone works through a subject rather than tests themselves on all of it. Membership is snapshotted, as with any plan — a plan you are part-way through must not reshuffle between visits — and no question appears in two blocks of the same plan. """ slug = data.slug.strip().lower() if not SLUG_RE.match(slug): raise HTTPException(400, "A slug is lowercase words joined by hyphens") if db.query(StudyPlan.id).filter(StudyPlan.slug == slug).first(): raise HTTPException(409, "A plan with that slug already exists") if data.shape not in ("papers", "domains"): raise HTTPException(400, "Shape must be papers or domains") exam = db.get(Exam, data.exam_id) if not exam: raise HTTPException(404, "Exam not found") if not exam_blueprint.domains(db, data.exam_id): raise HTTPException(400, "That exam has no blueprint to build from") keep = general_question_predicate() if data.shape == "papers": drawn = exam_blueprint.sample_papers(db, data.exam_id, data.block_size, data.blocks, predicate=keep) built = [(None, f"Paper {i + 1}", ids) for i, (ids, _) in enumerate(drawn)] else: built = exam_blueprint.domain_blocks(db, data.exam_id, data.block_size, data.blocks, predicate=keep) if not built: raise HTTPException(400, "No questions match this blueprint yet") plan = StudyPlan(slug=slug, name=data.name.strip(), description=data.description, kind="set", exam_id=data.exam_id, sort_order=data.sort_order, is_published=1 if data.is_published else 0) db.add(plan) db.flush() # Reading per domain, so a block opens on what prepares you for it. reading: dict[str, list[int]] = {} if data.with_reading: for line in exam_blueprint.domains(db, data.exam_id): cats = exam_blueprint.categories_for(db, line.id) if not cats: continue reading[line.code] = [row[0] for row in db.query(Article.id).filter( Article.category_id.in_(cats), Article.status == "published").limit(6).all()] for position, (code, title, ids) in enumerate(built): block = StudyPlanBlock(plan_id=plan.id, position=position, title=title, question_ids=list(ids)) db.add(block) db.flush() for index, article_id in enumerate(reading.get(code, [])): db.add(StudyPlanBlockArticle(block_id=block.id, article_id=article_id, position=index)) db.commit() return { "id": plan.id, "slug": plan.slug, "name": plan.name, "shape": data.shape, "blocks": [{"position": i, "title": title, "questions": len(ids)} for i, (_, title, ids) in enumerate(built)], "questions": sum(len(ids) for _, _, ids in built), } @router.patch("/{plan_id}") def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): plan = _get_plan(db, plan_id) values = data.model_dump(exclude_unset=True) if "is_published" in values: plan.is_published = 1 if values.pop("is_published") else 0 for field, value in values.items(): setattr(plan, field, value.strip() if isinstance(value, str) else value) db.commit() return {"id": plan.id} @router.delete("/{plan_id}", status_code=204) def delete_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Remove a plan and its blocks. Tests already generated from it survive. A learner part-way through keeps the quizzes they started; deleting a plan is retiring a route through the bank, not confiscating anyone's work. """ db.delete(_get_plan(db, plan_id)) db.commit() class BlockWrite(BaseModel): title: str = Field(min_length=1, max_length=200) question_ids: list[int] = [] @router.post("/{plan_id}/blocks", status_code=201) def add_block(plan_id: int, data: BlockWrite, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): plan = _get_plan(db, plan_id) position = max((b.position for b in plan.blocks), default=-1) + 1 block = StudyPlanBlock(plan_id=plan.id, position=position, title=data.title.strip(), question_ids=list(dict.fromkeys(data.question_ids))) db.add(block) db.commit() return {"id": block.id, "position": block.position, "title": block.title} class BlockUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=200) question_ids: list[int] | None = None @router.patch("/blocks/{block_id}") def update_block(block_id: int, data: BlockUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): block = _get_block(db, block_id) values = data.model_dump(exclude_unset=True) if values.get("title"): block.title = values["title"].strip() if values.get("question_ids") is not None: ids = list(dict.fromkeys(values["question_ids"])) known = {qid for (qid,) in db.query(Question.id).filter(Question.id.in_(ids)).all()} missing = [qid for qid in ids if qid not in known] if missing: raise HTTPException(400, f"No such question: {', '.join(str(q) for q in missing[:5])}") block.question_ids = ids db.commit() return {"id": block.id, "question_count": len(block.question_ids or [])} @router.delete("/blocks/{block_id}", status_code=204) def delete_block(block_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Delete a block and close the gap it leaves in the numbering.""" block = _get_block(db, block_id) plan_id, position = block.plan_id, block.position db.delete(block) db.flush() # Positions are unique per plan, so the survivors have to shuffle down or # the next insert collides with a number nothing occupies. for other in db.query(StudyPlanBlock).filter( StudyPlanBlock.plan_id == plan_id, StudyPlanBlock.position > position).order_by(StudyPlanBlock.position).all(): other.position -= 1 db.commit() class BlockOrder(BaseModel): block_ids: list[int] = Field(min_length=1) @router.post("/{plan_id}/blocks/order") def reorder_blocks(plan_id: int, data: BlockOrder, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Set the order of a plan's blocks in one go.""" plan = _get_plan(db, plan_id) blocks = {block.id: block for block in plan.blocks} if set(data.block_ids) != set(blocks): raise HTTPException(400, "List every block of this plan exactly once") # Two passes through a unique (plan_id, position) constraint: park the rows # out of range first, or the first move collides with a position still held. for offset, block_id in enumerate(data.block_ids): blocks[block_id].position = -1000 - offset db.flush() for position, block_id in enumerate(data.block_ids): blocks[block_id].position = position db.commit() return {"plan_id": plan.id, "blocks": data.block_ids} class MoveQuestions(BaseModel): question_ids: list[int] = Field(min_length=1) to_block_id: int @router.post("/blocks/{block_id}/move") def move_questions(block_id: int, data: MoveQuestions, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Move questions from one block to another within the same plan.""" source = _get_block(db, block_id) target = _get_block(db, data.to_block_id) if source.id == target.id: raise HTTPException(400, "Pick a different block to move into") if source.plan_id != target.plan_id: raise HTTPException(400, "Blocks belong to different plans") moving = [qid for qid in data.question_ids if qid in (source.question_ids or [])] if not moving: raise HTTPException(400, "None of those questions are in this block") source.question_ids = [qid for qid in (source.question_ids or []) if qid not in moving] target.question_ids = list(dict.fromkeys([*(target.question_ids or []), *moving])) db.commit() return {"moved": len(moving), "from": source.id, "to": target.id} class BlockArticleIn(BaseModel): article_id: int @router.post("/blocks/{block_id}/articles", status_code=201) def attach_article(block_id: int, data: BlockArticleIn, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Attach reading to a block, appended after whatever is already there.""" block = _get_block(db, block_id) if not db.get(Article, data.article_id): raise HTTPException(404, "Article not found") if db.query(StudyPlanBlockArticle.id).filter_by( block_id=block.id, article_id=data.article_id).first(): raise HTTPException(409, "That article is already on this block") position = (db.query(StudyPlanBlockArticle).filter_by(block_id=block.id).count()) link = StudyPlanBlockArticle(block_id=block.id, article_id=data.article_id, position=position) db.add(link) db.commit() return {"link_id": link.id, "block_id": block.id, "article_id": data.article_id} @router.delete("/reading/{link_id}", status_code=204) def detach_article(link_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): link = db.get(StudyPlanBlockArticle, link_id) if not link: raise HTTPException(404, "That reading is not part of a block") db.delete(link) db.commit()