Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
108 lines
4.8 KiB
Python
108 lines
4.8 KiB
Python
"""Helpers for managing the quiz ↔ question junction table."""
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.question import Question
|
|
from app.models.quiz_question_link import QuizQuestionLink
|
|
|
|
|
|
def validate_key_points(key_points, db=None):
|
|
"""Key points are short text items that may smart-link to an article section."""
|
|
if key_points is None:
|
|
return None
|
|
if not isinstance(key_points, list) or len(key_points) > 12:
|
|
raise HTTPException(400, "Key points must be a list of up to 12 items")
|
|
from app.models.article import Article
|
|
result = []
|
|
for item in key_points:
|
|
if not isinstance(item, dict) or not isinstance(item.get("text"), str) or not item["text"].strip():
|
|
raise HTTPException(400, "Each key point needs text")
|
|
text = item["text"].strip()
|
|
if len(text) > 300:
|
|
raise HTTPException(400, "Key point text is too long (max 300 characters)")
|
|
article_id = item.get("article_id")
|
|
section_id = item.get("article_section_id")
|
|
if article_id is not None:
|
|
article = db.get(Article, int(article_id)) if db else None
|
|
if not article:
|
|
raise HTTPException(400, "Linked article not found")
|
|
if section_id and section_id not in {s["id"] for s in (article.sections or [])}:
|
|
raise HTTPException(400, "Linked article section not found")
|
|
result.append({"text": text, "article_id": int(article_id) if article_id is not None else None,
|
|
"article_section_id": section_id or None})
|
|
return result
|
|
|
|
|
|
def validate_option_explanations(options, explanations):
|
|
"""Per-option explanations must be a {option_text: explanation} map within options."""
|
|
if explanations is None:
|
|
return None
|
|
if not isinstance(explanations, dict):
|
|
raise HTTPException(400, "Option explanations must be an object keyed by option text")
|
|
allowed = set(options or [])
|
|
for key, value in explanations.items():
|
|
if not isinstance(key, str) or key not in allowed:
|
|
raise HTTPException(400, "Option explanation keys must match existing options")
|
|
if not isinstance(value, str) or len(value) > 2000:
|
|
raise HTTPException(400, "Option explanations are strings up to 2000 characters")
|
|
return {key: value for key, value in explanations.items() if value.strip()}
|
|
|
|
|
|
def get_quiz_questions(db: Session, quiz_id: int) -> list[Question]:
|
|
"""Fetch questions for a quiz in position order via junction table."""
|
|
links = (
|
|
db.query(QuizQuestionLink)
|
|
.filter(QuizQuestionLink.quiz_id == quiz_id)
|
|
.order_by(QuizQuestionLink.position)
|
|
.all()
|
|
)
|
|
if not links:
|
|
return []
|
|
q_map = {
|
|
q.id: q
|
|
for q in db.query(Question).filter(Question.id.in_([l.question_id for l in links])).all()
|
|
}
|
|
return [q_map[l.question_id] for l in links if l.question_id in q_map]
|
|
|
|
|
|
def grade_quiz_answers(questions, answers, selected_ids=None):
|
|
"""Grade each selected question once; omissions are recorded as incorrect."""
|
|
question_map = {q.id: q for q in questions}
|
|
ids = list(question_map) if selected_ids is None else selected_ids
|
|
if not ids or len(ids) != len(set(ids)) or set(ids) - question_map.keys():
|
|
raise HTTPException(400, "Question selection is invalid or no longer available")
|
|
submitted = dict(answers)
|
|
if len(submitted) != len(answers) or submitted.keys() - set(ids) or any(not isinstance(value, str) for value in submitted.values()):
|
|
raise HTTPException(400, "Answers contain duplicate or unselected question IDs")
|
|
grades = []
|
|
for qid in ids:
|
|
question = question_map[qid]
|
|
answer = submitted.get(qid, "")
|
|
correct = bool(answer.strip()) and bool(question.correct_answer) and answer.strip().lower() == question.correct_answer.strip().lower()
|
|
grades.append((question, answer, correct))
|
|
return grades
|
|
|
|
|
|
def add_questions_to_quiz(db: Session, quiz_id: int, question_ids: list[int], start_pos: int = 0):
|
|
"""Add question links to a quiz (skips duplicates)."""
|
|
existing = {
|
|
l.question_id
|
|
for l in db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all()
|
|
}
|
|
for i, qid in enumerate(question_ids):
|
|
if qid not in existing:
|
|
db.add(QuizQuestionLink(quiz_id=quiz_id, question_id=qid, position=start_pos + i))
|
|
|
|
|
|
def question_in_quiz(db: Session, quiz_id: int, question_id: int) -> bool:
|
|
return db.query(QuizQuestionLink).filter(
|
|
QuizQuestionLink.quiz_id == quiz_id,
|
|
QuizQuestionLink.question_id == question_id,
|
|
).first() is not None
|
|
|
|
|
|
def remove_question_from_quiz(db: Session, quiz_id: int, question_id: int):
|
|
db.query(QuizQuestionLink).filter(
|
|
QuizQuestionLink.quiz_id == quiz_id,
|
|
QuizQuestionLink.question_id == question_id,
|
|
).delete()
|