`Question.is_shared` defaulted to 1 and was only ever set by a route nothing called, so in practice it divided the bank into "everything" and "everything, plus your own private ones" — a distinction that cost every recommendation denominator a join and never changed an answer. Who may reach the bank is the site's own access rules; who may manage a question is the category grant tree. So the two predicates the whole bank was built on are now the same thing, and say what they actually mean: a question is out of reach if it has been deleted or belongs to a course. Nothing else. The column is dropped, the route that set it is gone, the bulk "share" action with it, and the Private tile and pill go from the question manager. The tests that turned on it have been rewritten rather than deleted, because the rule they were really about survives: revoking a question still revokes every session carrying it — by deleting it, which is the only revocation left. Several others named a category holding exactly two reachable questions and then answered two particular ids; that category holds four now, so they name the pair instead. A session's own sharing flag is untouched — that is a different thing, and it is still how a session is handed to somebody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
"""Drafts, and the one moment they become questions.
|
|
|
|
The rule the whole arrangement exists for: a draft has no question id. Ids come
|
|
from a sequence and are never reissued, so a machine's first attempt taking one
|
|
the moment it is produced means every rejected draft burns an id, and every
|
|
draft being fixed is in the bank while it is being fixed.
|
|
|
|
Accepting is therefore the only place a `Question` is created, and it is a copy
|
|
rather than a translation — every field a draft can hold is a field a question
|
|
has, so nothing is lost at the moment of acceptance.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.draft_question import DraftBatch, DraftQuestion
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategory
|
|
from app.models.user import User
|
|
|
|
#: What a question needs before anyone can sit it. Checked at acceptance rather
|
|
#: than at extraction, because a draft is allowed to be incomplete — that is
|
|
#: what it is for.
|
|
def problems(draft: DraftQuestion) -> list[str]:
|
|
found = []
|
|
if not (draft.question_text or "").strip():
|
|
found.append("no question text")
|
|
if draft.question_type == "mcq":
|
|
options = draft.options or []
|
|
if len(options) < 2:
|
|
found.append("fewer than two options")
|
|
elif not draft.correct_answer:
|
|
found.append("no correct answer marked")
|
|
elif draft.correct_answer not in options:
|
|
found.append("the correct answer is not one of the options")
|
|
elif not (draft.correct_answer or "").strip():
|
|
found.append("no correct answer")
|
|
return found
|
|
|
|
|
|
def category_for(draft: DraftQuestion, batch: DraftBatch) -> int | None:
|
|
"""A draft's own category if it has one, otherwise the batch's."""
|
|
return draft.category_id or batch.category_id
|
|
|
|
|
|
def accept(db: Session, draft: DraftQuestion, batch: DraftBatch, user: User) -> Question:
|
|
"""Copy one draft into the bank. This is where the id is taken."""
|
|
if draft.status == "accepted" and draft.question_id:
|
|
raise HTTPException(409, "That draft has already been accepted")
|
|
faults = problems(draft)
|
|
if faults:
|
|
raise HTTPException(400, f"Not ready: {', '.join(faults)}")
|
|
|
|
category_id = category_for(draft, batch)
|
|
# A question with no category reaches nothing: no discipline, no organ
|
|
# system, no relevance, and no row on any tab of the analysis. It would sit
|
|
# in the bank and be invisible to every page that counts. The batch carries
|
|
# one so that saying it once covers the whole extraction.
|
|
if category_id is None:
|
|
raise HTTPException(400, "Give this batch a category before accepting from it — "
|
|
"a question filed nowhere is invisible to the analysis")
|
|
if not db.get(QuestionCategory, category_id):
|
|
raise HTTPException(400, "The category this batch files into no longer exists")
|
|
|
|
question = Question(
|
|
question_category_id=category_id,
|
|
question_text=draft.question_text.strip(),
|
|
question_type=draft.question_type,
|
|
options=draft.options,
|
|
correct_answer=draft.correct_answer,
|
|
explanation=draft.explanation,
|
|
option_explanations=draft.option_explanations,
|
|
key_points=draft.key_points,
|
|
attending_tip=draft.attending_tip,
|
|
difficulty=draft.difficulty,
|
|
page_reference=draft.page_reference,
|
|
image_path=draft.image_path,
|
|
explanation_image_path=draft.explanation_image_path,
|
|
user_id=user.id,
|
|
)
|
|
db.add(question)
|
|
db.flush()
|
|
|
|
# The draft keeps its row and records what it became, so the batch reads as
|
|
# a history of what was decided rather than emptying as it is worked through.
|
|
draft.status = "accepted"
|
|
draft.question_id = question.id
|
|
draft.decided_by = user.id
|
|
draft.decided_at = datetime.utcnow()
|
|
return question
|
|
|
|
|
|
def reject(db: Session, draft: DraftQuestion, user: User, note: str | None = None) -> None:
|
|
if draft.status == "accepted":
|
|
raise HTTPException(409, "That draft is already in the bank")
|
|
draft.status = "rejected"
|
|
draft.note = (note or "").strip() or None
|
|
draft.decided_by = user.id
|
|
draft.decided_at = datetime.utcnow()
|
|
|
|
|
|
def reopen(db: Session, draft: DraftQuestion) -> None:
|
|
"""Undo a rejection. An accepted draft cannot be reopened — the question
|
|
exists, and deciding again would make a second one."""
|
|
if draft.status == "accepted":
|
|
raise HTTPException(409, "That draft is in the bank; edit or delete the question instead")
|
|
draft.status = "pending"
|
|
draft.note = None
|
|
draft.decided_by = None
|
|
draft.decided_at = None
|
|
|
|
|
|
def as_json(draft: DraftQuestion) -> dict:
|
|
return {
|
|
"id": draft.id,
|
|
"position": draft.position,
|
|
"question_text": draft.question_text,
|
|
"question_type": draft.question_type,
|
|
"options": draft.options,
|
|
"correct_answer": draft.correct_answer,
|
|
"explanation": draft.explanation,
|
|
"option_explanations": draft.option_explanations,
|
|
"key_points": draft.key_points,
|
|
"attending_tip": draft.attending_tip,
|
|
"difficulty": draft.difficulty,
|
|
"page_reference": draft.page_reference,
|
|
"image_path": draft.image_path,
|
|
"explanation_image_path": draft.explanation_image_path,
|
|
"category_id": draft.category_id,
|
|
"status": draft.status,
|
|
"question_id": draft.question_id,
|
|
"note": draft.note,
|
|
"edited": bool(draft.edited),
|
|
# Said for every draft, not only on the attempt to accept it, so a
|
|
# reviewer can see what needs work before opening anything.
|
|
"problems": problems(draft),
|
|
}
|
|
|
|
|
|
def batch_json(db: Session, batch: DraftBatch, counts: dict[str, int] | None = None) -> dict:
|
|
return {
|
|
"id": batch.id,
|
|
"title": batch.title,
|
|
"document_id": batch.document_id,
|
|
"section_id": batch.section_id,
|
|
"model_id": batch.model_id,
|
|
"extraction_mode": batch.extraction_mode,
|
|
"category_id": batch.category_id,
|
|
"status": batch.status,
|
|
"created_at": batch.created_at,
|
|
"counts": counts or {},
|
|
}
|