There will be no courses. What was there: one draft called "jk" with two empty lessons, and 4,000 lines of code around it — courses, modules, lessons, enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates, three React pages, a router, two models. Its real cost was everywhere else. Every query that measured practice had to remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have silently mixed course attempts into a learner's analytics; the bank predicate carried a subquery to exclude a course's own questions from every search, recommendation and share; quiz access had a second, parallel rule about enrolment. All of that is gone, so the remaining rules say what they mean. `quizzes.allow_review` goes with it. It was only ever enforced for a course quiz, so it had become a promise nothing keeps — the public session page was still offering "no answer review" about sessions that review fine. The fixtures' question 5 lived in a course quiz and stood for "a question that exists but is not in your bank". There is no such thing now — a question is in the bank unless it is deleted — so the counts it kept out of the numbers are back in, and the tests that turned on it now turn on deletion or on the attempt that actually holds a question. Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in app/utils/upload_access.py is what keeps them unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
79 lines
2.1 KiB
Python
79 lines
2.1 KiB
Python
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class AnswerSubmission(BaseModel):
|
|
question_id: int
|
|
user_answer: str
|
|
|
|
|
|
class AttemptSubmit(BaseModel):
|
|
answers: list[AnswerSubmission]
|
|
# {question_id: seconds}. Absent for a client that does not measure, which is
|
|
# why the column is nullable rather than defaulted to zero.
|
|
timings: dict[int, int] | None = None
|
|
# Question ids where the learner opened a tip before answering. Getting it
|
|
# right after a nudge is not the same as getting it right, and the two are
|
|
# worth telling apart without calling either of them wrong.
|
|
hints: list[int] | None = None
|
|
|
|
|
|
class AnswerDetail(BaseModel):
|
|
question_id: int
|
|
question_text: str
|
|
question_type: str
|
|
options: list[str] | None = None
|
|
user_answer: str
|
|
correct_answer: str
|
|
is_correct: bool
|
|
explanation: str | None
|
|
explanation_image_path: str | None = None
|
|
image_path: str | None = None
|
|
page_reference: int | None = None
|
|
category_breadcrumbs: list[dict] = Field(default_factory=list)
|
|
figures: list[dict] = Field(default_factory=list)
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class AttemptResponse(BaseModel):
|
|
id: int
|
|
quiz_id: int
|
|
score: int
|
|
total_questions: int
|
|
percentage: float
|
|
started_at: datetime
|
|
completed_at: datetime | None
|
|
mode: str | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class AttemptDetail(AttemptResponse):
|
|
answers: list[AnswerDetail] = []
|
|
|
|
|
|
# Stats schemas
|
|
class QuizStats(BaseModel):
|
|
quiz_id: int
|
|
quiz_title: str
|
|
attempts_count: int
|
|
best_score: float
|
|
latest_score: float
|
|
average_score: float
|
|
|
|
|
|
class DashboardStats(BaseModel):
|
|
total_documents: int
|
|
total_quizzes: int
|
|
total_attempts: int
|
|
average_score: float
|
|
quiz_stats: list[QuizStats] = []
|
|
# Question-centred figures. The quiz counts above describe how the material
|
|
# happens to be packaged; these describe what the learner has worked through.
|
|
questions_seen: int = 0
|
|
questions_correct: int = 0
|
|
bank_total: int = 0
|