"""Extracted questions, before they are questions. A batch is one run of extraction. It is read, edited and decided here, and nothing reaches the question bank — or takes a question id — until somebody accepts it. """ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import func from sqlalchemy.orm import Session from app.database import get_db from app.models.draft_question import DraftBatch, DraftQuestion from app.models.question_category import QuestionCategory from app.models.user import User from app.services import draft_questions as drafts from app.utils.auth import get_current_user, require_moderator from app.utils.quiz_questions import validate_key_points, validate_option_explanations router = APIRouter() def _counts(db: Session, batch_ids: list[int]) -> dict[int, dict[str, int]]: if not batch_ids: return {} rows = (db.query(DraftQuestion.batch_id, DraftQuestion.status, func.count(DraftQuestion.id)) .filter(DraftQuestion.batch_id.in_(batch_ids)) .group_by(DraftQuestion.batch_id, DraftQuestion.status).all()) out: dict[int, dict[str, int]] = {} for batch_id, status, count in rows: out.setdefault(batch_id, {})[status] = count return out def _get_batch(db: Session, batch_id: int) -> DraftBatch: batch = db.get(DraftBatch, batch_id) if not batch: raise HTTPException(404, "Batch not found") return batch def _get_draft(db: Session, draft_id: int) -> tuple[DraftQuestion, DraftBatch]: draft = db.get(DraftQuestion, draft_id) if not draft: raise HTTPException(404, "Draft not found") return draft, _get_batch(db, draft.batch_id) @router.get("/batches") def list_batches( status: str | None = Query(None), db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): query = db.query(DraftBatch) if status: query = query.filter(DraftBatch.status == status) batches = query.order_by(DraftBatch.created_at.desc()).limit(200).all() counts = _counts(db, [b.id for b in batches]) return [drafts.batch_json(db, b, counts.get(b.id, {})) for b in batches] @router.get("/batches/{batch_id}") def read_batch( batch_id: int, status: str | None = Query(None), db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): batch = _get_batch(db, batch_id) query = db.query(DraftQuestion).filter(DraftQuestion.batch_id == batch_id) if status: query = query.filter(DraftQuestion.status == status) rows = query.order_by(DraftQuestion.position, DraftQuestion.id).all() body = drafts.batch_json(db, batch, _counts(db, [batch_id]).get(batch_id, {})) body["drafts"] = [drafts.as_json(d) for d in rows] return body class BatchEdit(BaseModel): title: str | None = None category_id: int | None = None status: str | None = None @router.patch("/batches/{batch_id}") def update_batch( batch_id: int, data: BatchEdit, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): batch = _get_batch(db, batch_id) fields = data.model_dump(exclude_unset=True) if fields.get("status") and fields["status"] not in ("open", "closed"): raise HTTPException(400, "A batch is open or closed") if fields.get("category_id") is not None and not db.get(QuestionCategory, fields["category_id"]): raise HTTPException(400, "Category not found") for key, value in fields.items(): setattr(batch, key, value) db.commit() return drafts.batch_json(db, batch, _counts(db, [batch_id]).get(batch_id, {})) @router.delete("/batches/{batch_id}", status_code=204) def delete_batch( batch_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Throw a whole run away. Questions already accepted from it stay — they are in the bank now and are nothing to do with the batch any more.""" db.delete(_get_batch(db, batch_id)) db.commit() class DraftEdit(BaseModel): question_text: str | None = None question_type: str | None = None options: list[str] | None = None correct_answer: str | None = None explanation: str | None = None option_explanations: dict | None = None key_points: list[dict] | None = None attending_tip: str | None = None difficulty: str | None = None category_id: int | None = None @router.patch("/{draft_id}") def update_draft( draft_id: int, data: DraftEdit, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Fix a draft in place. This is the whole point of the staging table: it can be wrong, and be corrected, without anything being in the bank.""" draft, _ = _get_draft(db, draft_id) if draft.status == "accepted": raise HTTPException(409, "That draft is in the bank; edit the question instead") fields = data.model_dump(exclude_unset=True) if "difficulty" in fields and fields["difficulty"] not in (None, "easy", "medium", "hard"): raise HTTPException(422, "Difficulty is easy, medium or hard") options = fields.get("options", draft.options) if "option_explanations" in fields: fields["option_explanations"] = validate_option_explanations(options, fields["option_explanations"]) if "key_points" in fields: fields["key_points"] = validate_key_points(fields["key_points"], db) if fields.get("category_id") is not None and not db.get(QuestionCategory, fields["category_id"]): raise HTTPException(400, "Category not found") for key, value in fields.items(): setattr(draft, key, value) # Worth knowing about a model's output: how much of it a human had to change. draft.edited = 1 db.commit() return drafts.as_json(draft) class Decision(BaseModel): ids: list[int] = Field(min_length=1, max_length=200) note: str | None = None @router.post("/accept") def accept_drafts( data: Decision, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Put drafts into the bank. Each one becomes a question here and nowhere else, which is the only moment a question id is taken. All or nothing: a batch half-accepted because the twentieth draft was missing an answer is worse than a refusal naming it. """ rows = db.query(DraftQuestion).filter(DraftQuestion.id.in_(data.ids)).all() missing = set(data.ids) - {row.id for row in rows} if missing: raise HTTPException(404, f"No such draft: {', '.join(str(m) for m in sorted(missing))}") batches = {b.id: b for b in db.query(DraftBatch).filter( DraftBatch.id.in_({row.batch_id for row in rows})).all()} # Everything is checked before anything is created. Creating as we go and # refusing part-way leaves questions in the bank from a call that reported # failure — and the caller has no way to know which. # Already in the bank is a different answer from not ready: one is a # conflict with what has happened, the other is work still to do. done = [row.id for row in rows if row.status == "accepted" and row.question_id] if done: raise HTTPException(409, "Already in the bank: " f"{', '.join(f'draft {i}' for i in sorted(done))}") faults = [] for row in rows: found = drafts.problems(row) if found: faults.append(f"draft {row.id}: {', '.join(found)}") if faults: raise HTTPException(400, "; ".join(faults[:10])) made = [] try: for row in sorted(rows, key=lambda r: (r.batch_id, r.position, r.id)): question = drafts.accept(db, row, batches[row.batch_id], current_user) made.append({"draft_id": row.id, "question_id": question.id}) db.commit() except Exception: db.rollback() raise # A batch with nothing left to decide closes itself. for batch_id in {row.batch_id for row in rows}: left = db.query(DraftQuestion.id).filter( DraftQuestion.batch_id == batch_id, DraftQuestion.status == "pending").first() if not left: db.get(DraftBatch, batch_id).status = "closed" db.commit() return {"accepted": len(made), "questions": made} @router.post("/reject") def reject_drafts( data: Decision, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): rows = db.query(DraftQuestion).filter(DraftQuestion.id.in_(data.ids)).all() for row in rows: drafts.reject(db, row, current_user, data.note) db.commit() return {"rejected": len(rows)} @router.post("/{draft_id}/reopen") def reopen_draft( draft_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): draft, _ = _get_draft(db, draft_id) drafts.reopen(db, draft) db.commit() return drafts.as_json(draft) @router.delete("/{draft_id}", status_code=204) def delete_draft( draft_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Remove a draft outright. Nothing in the bank is touched: a draft that was accepted has already become a question, which is its own row.""" draft, _ = _get_draft(db, draft_id) db.delete(draft) db.commit()