diff --git a/backend/alembic/versions/d94a26b8f302_study_tools.py b/backend/alembic/versions/d94a26b8f302_study_tools.py new file mode 100644 index 0000000..ac4d510 --- /dev/null +++ b/backend/alembic/versions/d94a26b8f302_study_tools.py @@ -0,0 +1,30 @@ +"""Persist attempt mode and educator-maintained lab references.""" +from alembic import op + +revision = "d94a26b8f302" +down_revision = "c82d19e4a601" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE quiz_attempts ADD COLUMN IF NOT EXISTS mode VARCHAR(10)") + op.execute('''CREATE TABLE IF NOT EXISTS lab_reference_values ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + "group" VARCHAR(60) NOT NULL, + reference_range VARCHAR(250) NOT NULL, + units VARCHAR(80) NOT NULL, + age_group VARCHAR(120) NOT NULL, + specimen VARCHAR(120) NOT NULL, + source VARCHAR(500) NOT NULL, + source_url VARCHAR(2000), + is_published BOOLEAN NOT NULL DEFAULT FALSE, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + )''') + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS lab_reference_values") + op.execute("ALTER TABLE quiz_attempts DROP COLUMN IF EXISTS mode") diff --git a/backend/app/main.py b/backend/app/main.py index 179a790..994e964 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote +from app.routers import study_tools from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -633,6 +634,7 @@ app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcard app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"]) app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) +app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) @app.get("/api/health") diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 3798ac4..802de0d 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -12,6 +12,7 @@ class QuizAttempt(Base): id = Column(Integer, primary_key=True, index=True) quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + mode = Column(String(10), nullable=True) # legacy NULL resumes without exposing answers score = Column(Integer, default=0) total_questions = Column(Integer, default=0) started_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/lab_reference.py b/backend/app/models/lab_reference.py new file mode 100644 index 0000000..11cbd30 --- /dev/null +++ b/backend/app/models/lab_reference.py @@ -0,0 +1,20 @@ +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from app.database import Base + + +class LabReference(Base): + __tablename__ = "lab_reference_values" + + id = Column(Integer, primary_key=True) + name = Column(String(120), nullable=False) + group = Column(String(60), nullable=False) + reference_range = Column(String(250), nullable=False) + units = Column(String(80), nullable=False) + age_group = Column(String(120), nullable=False) + specimen = Column(String(120), nullable=False) + source = Column(String(500), nullable=False) + source_url = Column(String(2000), nullable=True) + is_published = Column(Boolean, nullable=False, default=False, server_default="false") + updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 141c92e..ac5e363 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request @@ -11,6 +12,8 @@ from sqlalchemy import func from app.database import get_db from app.models.quiz import Quiz from app.models.question import Question +from app.models.question_category import QuestionCategory +from app.services.quiz_builder import category_breadcrumbs from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.pdf_document import PDFDocument from app.models.user import User @@ -33,6 +36,7 @@ router = APIRouter() def start_attempt( quiz_id: int, fresh: bool = False, + mode: Literal["study", "exam"] | None = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -42,6 +46,10 @@ def start_attempt( if not can_access_quiz(db, quiz, current_user): raise HTTPException(status_code=403, detail="This quiz is private") + chosen_mode = mode or ("study" if quiz.mode == "learning" else "exam") + if quiz.course_id is not None: + chosen_mode = "study" if quiz.mode == "learning" and quiz.allow_review == 1 else "exam" + # Enforce max_attempts if quiz.max_attempts: completed_count = db.query(QuizAttempt).filter( @@ -68,6 +76,7 @@ def start_attempt( percentage=0.0, started_at=existing.started_at, completed_at=None, + mode=existing.mode or "exam", ) # Question pool: randomly select N questions if questions_per_attempt is set @@ -84,6 +93,7 @@ def start_attempt( user_id=current_user.id, total_questions=total_q, selected_question_ids=selected_ids, + mode=chosen_mode, ) db.add(attempt) db.commit() @@ -96,6 +106,7 @@ def start_attempt( percentage=0.0, started_at=attempt.started_at, completed_at=None, + mode=attempt.mode, ) @@ -118,6 +129,7 @@ def submit_attempt( quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() require_quiz_access(db, quiz, current_user) + categories = db.query(QuestionCategory).all() grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), [(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids) score = sum(correct for _, _, correct in grades) @@ -142,6 +154,9 @@ def submit_attempt( is_correct=is_correct, explanation=q.explanation, explanation_image_path=q.explanation_image_path, + image_path=q.image_path, + page_reference=q.page_reference, + category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id), )) attempt.score = score @@ -258,7 +273,7 @@ def save_progress( "attempt_id": data.attempt_id, "answers": data.answers, "current_idx": data.current_idx, - "mode": data.mode, + "mode": attempt.mode or "exam", "voice": data.voice, "time_left": data.time_left, "started_at": data.started_at, @@ -311,6 +326,7 @@ def get_progress( return None saved = _json.loads(data) + saved["mode"] = attempt.mode or "exam" # If the quiz was suspended, timer is paused — re-anchor on resume so # the held time_left becomes the new total_time starting now. @@ -588,6 +604,7 @@ def get_attempt( is_course_quiz = quiz and quiz.course_id is not None review_allowed = not is_course_quiz or (quiz.allow_review == 1) + categories = db.query(QuestionCategory).all() # Never reveal review content for an unfinished attempt or an unselected pool question. review_allowed = review_allowed and attempt.completed_at is not None submitted_map = {ans.question_id: ans for ans in attempt.answers} @@ -607,6 +624,9 @@ def get_attempt( is_correct=ans.is_correct if ans else False, explanation=q.explanation, explanation_image_path=q.explanation_image_path, + image_path=q.image_path, + page_reference=q.page_reference, + category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id), )) percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index fbc4934..f5f9f92 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -10,6 +10,8 @@ from app.models.question import Question as QuestionModel from app.models.section import Section from app.models.attempt import QuizAttempt from app.models.user import User +from app.models.question_category import QuestionCategory +from app.services.quiz_builder import category_breadcrumbs from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.services import quiz_service from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared @@ -286,33 +288,32 @@ def get_quiz( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Get quiz for taking. study=true forces answers/explanations to be included. - attempt_id filters to the question subset selected for that attempt (question pool).""" + """Return selected questions; only an authorized study/completed attempt can reveal answers.""" quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") if not can_access_quiz(db, quiz, current_user): raise HTTPException(status_code=403, detail="This quiz is private") - if study or quiz.mode == "learning": - if quiz.mode != "learning": - require_quiz_access(db, quiz, current_user, review=True) - result = QuizLearningDetail.model_validate(quiz) - else: - result = QuizDetail.model_validate(quiz) - - # Filter questions if attempt has a question pool selection - if attempt_id: - from app.models.attempt import QuizAttempt - attempt = db.query(QuizAttempt).filter( - QuizAttempt.id == attempt_id, - QuizAttempt.user_id == current_user.id, - ).first() - if attempt and attempt.selected_question_ids: - sel = set(attempt.selected_question_ids) - result.questions = [q for q in result.questions if q.id in sel] - result.questions_count = len(result.questions) - + attempt = None + if attempt_id is not None: + attempt = db.query(QuizAttempt).filter_by(id=attempt_id, quiz_id=quiz.id, user_id=current_user.id).first() + if not attempt: + raise HTTPException(404, "Attempt not found for this quiz") + reveal = attempt is not None and (attempt.mode == "study" or attempt.completed_at is not None) + if study and not reveal: + raise HTTPException(403, "Answers are hidden until the exam is submitted") + if reveal: + require_quiz_access(db, quiz, current_user, review=True) + result = (QuizLearningDetail if reveal else QuizDetail).model_validate(quiz) + result.attempt_mode = (attempt.mode or "exam") if attempt else None + if attempt and attempt.selected_question_ids is not None: + selected = set(attempt.selected_question_ids) + result.questions = [q for q in result.questions if q.id in selected] + result.questions_count = len(result.questions) + categories = db.query(QuestionCategory).all() + for question in result.questions: + question.category_breadcrumbs = category_breadcrumbs(categories, question.question_category_id) return result diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py new file mode 100644 index 0000000..3a576d2 --- /dev/null +++ b/backend/app/routers/study_tools.py @@ -0,0 +1,129 @@ +"""Educator-maintained lab references and authorized question response statistics.""" +from collections import Counter +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.attempt import AttemptAnswer, QuizAttempt +from app.models.lab_reference import LabReference +from app.models.question import Question +from app.models.quiz import Quiz +from app.models.user import User +from app.utils.auth import get_current_user, require_moderator +from app.utils.quiz_access import general_quiz_visibility, require_quiz_access +from app.utils.quiz_questions import question_in_quiz + +router = APIRouter() + + +class LabInput(BaseModel): + name: str = Field(min_length=1, max_length=120) + group: str = Field(min_length=1, max_length=60) + reference_range: str = Field(min_length=1, max_length=250) + units: str = Field(min_length=1, max_length=80) + age_group: str = Field(min_length=1, max_length=120) + specimen: str = Field(min_length=1, max_length=120) + source: str = Field(min_length=1, max_length=500) + source_url: HttpUrl | None = None + is_published: bool = False + + @field_validator("source_url") + @classmethod + def bounded_source_url(cls, value): + if value is not None and len(str(value)) > 2000: + raise ValueError("Source URL must be at most 2000 characters") + return value + + @field_validator("name", "group", "reference_range", "units", "age_group", "specimen", "source") + @classmethod + def not_blank(cls, value): + if not value.strip(): + raise ValueError("A value is required") + return value.strip() + + +class LabOutput(LabInput): + model_config = ConfigDict(from_attributes=True) + id: int + updated_at: datetime + + +@router.get("/lab-values", response_model=list[LabOutput]) +def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + if include_drafts and not user.is_moderator: + raise HTTPException(403, "Educator access required") + query = db.query(LabReference) + if not include_drafts: + query = query.filter(LabReference.is_published.is_(True)) + # ponytail: bounded personal-project table; add pagination before exceeding 500 entries. + return query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all() + + +def lab_fields(data): + fields = data.model_dump() + fields["source_url"] = str(data.source_url) if data.source_url else None + return fields + + +@router.post("/lab-values", response_model=LabOutput, status_code=201) +def create_lab_value(data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + entry = LabReference(**lab_fields(data), updated_by=user.id) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +@router.put("/lab-values/{entry_id}", response_model=LabOutput) +def update_lab_value(entry_id: int, data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + entry = db.get(LabReference, entry_id) + if not entry: + raise HTTPException(404, "Reference not found") + for key, value in lab_fields(data).items(): + setattr(entry, key, value) + entry.updated_by = user.id + entry.updated_at = datetime.utcnow() + db.commit() + db.refresh(entry) + return entry + + +@router.delete("/lab-values/{entry_id}", status_code=204) +def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + entry = db.get(LabReference, entry_id) + if not entry: + raise HTTPException(404, "Reference not found") + db.delete(entry) + db.commit() + + +@router.get("/attempts/{attempt_id}/questions/{question_id}/responses") +def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first() + if not attempt: + raise HTTPException(404, "Attempt not found") + quiz = db.get(Quiz, attempt.quiz_id) + require_quiz_access(db, quiz, user, review=True) + if attempt.completed_at is None and attempt.mode != "study": + raise HTTPException(403, "Responses are hidden until the exam is submitted") + if not question_in_quiz(db, quiz.id, question_id) or (attempt.selected_question_ids is not None and question_id not in attempt.selected_question_ids): + raise HTTPException(404, "Question not selected for this attempt") + question = db.get(Question, question_id) + rows = db.query(AttemptAnswer.user_answer, func.count(AttemptAnswer.id)).join( + QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).filter( + AttemptAnswer.question_id == question_id, QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), general_quiz_visibility(user), + ).group_by(AttemptAnswer.user_answer).all() + counts = Counter() + for answer, count in rows: + if answer and answer.strip(): + counts[answer.strip().casefold()] += count + options = [{"option": option, "count": counts[option.strip().casefold()]} for option in (question.options or [])] + sample_size = sum(option["count"] for option in options) + for option in options: + option["percentage"] = round(100 * option["count"] / sample_size, 1) if sample_size else 0 + return {"sample_size": sample_size, "options": options, "basis": "Recorded answers from accessible completed general-bank attempts; skips and obsolete options excluded."} diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index d55f777..d996b89 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class AnswerSubmission(BaseModel): @@ -22,6 +22,9 @@ class AnswerDetail(BaseModel): 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) class Config: from_attributes = True @@ -35,6 +38,7 @@ class AttemptResponse(BaseModel): percentage: float started_at: datetime completed_at: datetime | None + mode: str | None = None course_id: int | None = None allow_review: bool = True diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 5ba607f..19f2263 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class QuizCreate(BaseModel): @@ -30,6 +30,8 @@ class QuestionResponse(BaseModel): question_type: str options: list[str] | None image_path: str | None = None + question_category_id: int | None = None + category_breadcrumbs: list[dict] = Field(default_factory=list) class Config: from_attributes = True @@ -57,6 +59,10 @@ class QuizResponse(BaseModel): is_published: int = 1 is_shared: int = 0 questions_per_attempt: int | None = None + course_id: int | None = None + allow_review: int = 1 + max_attempts: int | None = None + attempt_mode: str | None = None class Config: from_attributes = True diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py new file mode 100644 index 0000000..a141457 --- /dev/null +++ b/backend/tests/test_study_tools.py @@ -0,0 +1,107 @@ +import json +import sys +import unittest +from unittest.mock import Mock, patch + +import test_quiz_builder as fixtures +from app.models.attempt import QuizAttempt +from app.models.quiz import Quiz +from app.routers import study_tools + + +class StudyToolTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(study_tools.router, prefix='/study-tools') + + def tearDown(self): + self.bank.tearDown() + + def start(self, quiz_id, mode): + response = self.client.post(f'/attempts/start?quiz_id={quiz_id}&mode={mode}&fresh=true') + self.assertEqual(response.status_code, 200, response.text) + return response.json()['id'] + + def test_attempt_mode_controls_answers_and_stats_not_query_flags(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], mode='learning').json()['id'] + initial = self.client.get(f'/quizzes/{quiz_id}').json() + self.assertNotIn('correct_answer', initial['questions'][0]) + self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?study=true').status_code, 403) + exam = self.start(quiz_id, 'exam') + data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}').json() + self.assertEqual(data['attempt_mode'], 'exam') + self.assertNotIn('correct_answer', data['questions'][0]) + self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}&study=true').status_code, 403) + stats_url = f'/study-tools/attempts/{exam}/questions/1/responses' + self.assertEqual(self.client.get(stats_url).status_code, 403) + redis = Mock() + with patch.dict(sys.modules, {'redis': redis}): + self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': exam, 'answers': {}, 'current_idx': 0, 'mode': 'study'}) + stored = json.loads(redis.from_url.return_value.setex.call_args_list[-1].args[2]) + self.assertEqual(stored['mode'], 'exam') + study = self.start(quiz_id, 'study') + data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').json() + self.assertEqual(data['attempt_mode'], 'study') + self.assertEqual(data['questions'][0]['correct_answer'], 'yes') + self.assertTrue(data['questions'][0]['category_breadcrumbs']) + self.assertEqual(self.client.get('/quizzes/1', params={'attempt_id': study}).status_code, 404) + self.bank.user = self.bank.peer + self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').status_code, 404) + self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404) + + def test_real_response_counts_exclude_skips_expiry_course_and_private_attempts(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id'] + study = self.start(quiz_id, 'study') + self.bank.answer(1, True) + self.bank.answer(1, True, day=1) + self.bank.answer(1, False, day=2) + self.bank.answer(1, True, expired=1) + self.bank.answer(1, True, quiz_id=2) + self.bank.answer(1, True, completed=False) + other_private = Quiz(title='Private peer test', user_id=2, is_shared=0, is_published=0) + self.bank.db.add(other_private) + self.bank.db.commit() + self.bank.answer(1, True, quiz_id=other_private.id) + response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses') + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()['sample_size'], 3) + self.assertEqual([row['count'] for row in response.json()['options']], [2, 1]) + self.assertEqual([row['percentage'] for row in response.json()['options']], [66.7, 33.3]) + attempt = self.bank.db.get(QuizAttempt, study) + attempt.selected_question_ids = [2] + self.bank.db.commit() + self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404) + empty = self.client.get(f'/study-tools/attempts/{study}/questions/2/responses').json() + self.assertEqual(empty['sample_size'], 0) + self.assertTrue(all(row['percentage'] == 0 for row in empty['options'])) + + def test_lab_reference_permissions_validation_and_publication(self): + payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units', + age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference') + self.assertEqual(self.client.post('/study-tools/lab-values', json=payload).status_code, 403) + self.assertEqual(self.client.get('/study-tools/lab-values?include_drafts=true').status_code, 403) + self.bank.user = self.bank.mod + response = self.client.post('/study-tools/lab-values', json=payload) + self.assertEqual(response.status_code, 201, response.text) + entry_id = response.json()['id'] + self.assertFalse(response.json()['is_published']) + self.assertEqual(self.client.get('/study-tools/lab-values').json(), []) + self.assertEqual(len(self.client.get('/study-tools/lab-values?include_drafts=true').json()), 1) + for bad in ({'source': ' '}, {'source_url': 'javascript:alert(1)'}, {'source_url': 'https://example.com/' + 'a' * 2000}, {'units': ''}): + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, **bad}).status_code, 422) + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, 'is_published': True}).status_code, 200) + self.bank.user = self.bank.peer + rows = self.client.get('/study-tools/lab-values').json() + self.assertEqual(rows[0]['source'], payload['source']) + self.assertEqual(rows[0]['age_group'], payload['age_group']) + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json=payload).status_code, 403) + self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 403) + self.bank.user = self.bank.mod + self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 204) + self.assertEqual(self.client.get('/study-tools/lab-values').json(), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/test_study_tools_migration.py b/backend/tests/test_study_tools_migration.py new file mode 100644 index 0000000..277c4dc --- /dev/null +++ b/backend/tests/test_study_tools_migration.py @@ -0,0 +1,39 @@ +"""Offline PostgreSQL DDL test for the study tools migration; no database access.""" +import io +import os +from pathlib import Path +import unittest +from unittest.mock import patch + +os.environ['DATABASE_URL'] = 'sqlite:///:memory:' +from app.database import Base +from alembic import command +from alembic.config import Config +from alembic.script import ScriptDirectory + + +class StudyToolsMigrationTests(unittest.TestCase): + def test_explicit_offline_upgrade_and_downgrade(self): + output = io.StringIO() + config = Config(output_buffer=output) + config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic')) + scripts = ScriptDirectory.from_config(config) + self.assertEqual(len(scripts.get_heads()), 1) + self.assertEqual(scripts.get_revision('d94a26b8f302').down_revision, 'c82d19e4a601') + with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}): + command.upgrade(config, 'c82d19e4a601:d94a26b8f302', sql=True) + sql = output.getvalue() + self.assertIn('ADD COLUMN IF NOT EXISTS mode VARCHAR(10)', sql) + self.assertIn('CREATE TABLE IF NOT EXISTS lab_reference_values', sql) + self.assertIn('is_published BOOLEAN NOT NULL DEFAULT FALSE', sql) + self.assertIn('source VARCHAR(500) NOT NULL', sql) + self.assertNotIn('UPDATE quiz_attempts', sql) + output.seek(0) + output.truncate() + command.downgrade(config, 'd94a26b8f302:c82d19e4a601', sql=True) + self.assertIn('DROP TABLE IF EXISTS lab_reference_values', output.getvalue()) + self.assertIn('DROP COLUMN IF EXISTS mode', output.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md index 1862eff..5586c4b 100644 --- a/docs/quiz-revamp-progress.md +++ b/docs/quiz-revamp-progress.md @@ -21,7 +21,7 @@ Initial implementation received two independent read-only reviews (access/correc - PostgreSQL 16: actual migration upgrade, FK enforcement, existing-row/question-assignment preservation, repeated upgrade, downgrade and re-upgrade passed in a disposable network-isolated container. Container/data cleaned up automatically. - Alembic offline SQL: explicit `5f8c1c2a9d40:c82d19e4a601` upgrade and reverse downgrade exercised without mocking inspection. - Changed Python syntax and `git diff --check` passed. -- Regression negative-control run against the unfixed `affd717` is tracked separately; do not infer its result from the passing fixed-code suite. +- The same regression suite fails against frozen `affd717` as expected: unauthorized revocation/deletion, premature review answers, invalid mobile selections, duplicate scoring (200%) and offline SQL inspection are reproduced. The legacy-Hide test also catches its missing sharing-state response. All tests pass on the fixed code. The first Docker validation attempt failed before tests because a read-only mountpoint was absent; setup was corrected. One frontend regression initially used an ambiguous Create Quiz selector; the modal was labeled and the test scoped to it, then all tests passed. @@ -39,6 +39,20 @@ Verification: 18 backend tests passed in the exact deployed image, including log This source change is committed/pushed with the feature work; it has not been deployed to production. +## Milestone 2 — runner and study tools (under verification) + +Implemented a wide white/blue question layout with category breadcrumbs, numbered answers, provisional study selections, explicit response confirmation, real response-distribution bars, question navigation and Review & Complete. The results screen reviews one question at a time. Full question/explanation text, images, notes, highlighting and tutor access are retained. + +Added a safe arithmetic calculator (no eval), keyboard shortcuts that do not interfere with text entry/dialogs, native focus-trapping tool dialogs, and educator-managed lab references with required population/specimen/units/source and explicit publication. No clinical reference ranges are fabricated or seeded without verification. + +Attempt mode is persisted server-side: a query flag cannot reveal active exam answers/statistics, and mismatched attempt/quiz/user IDs are rejected. Legacy attempts without stored mode resume as exam mode rather than exposing answers; their saved answers remain intact. The new `d94a26b8f302` migration must run before deployment. + +Combined verification after integrating login removal: **22 backend tests passed in the deployed image; 48 frontend tests and production build passed.** Actual PostgreSQL mode/lab migration verification and independent review are being completed separately. + +Browser checks used a loopback-only fixture with synthetic accounts/questions and in-memory data, not production authentication. Desktop layout, provisional/confirmed answers, live fixture response counts, calculator arithmetic, honest empty lab references, and review confirmation were checked. At 390×844 the document width was exactly 390 and no question, option, image or toolbar overflowed; a taller narrow frame captures the full page. A browser-profile reset interrupted the check; re-login restored the saved attempt successfully. + +Proof images: [desktop](quiz-revamp/desktop-question.png), [study feedback](quiz-revamp/study-feedback.png), [mobile width](quiz-revamp/mobile-question.png). These are synthetic previews, not screenshots of deployed clinical content. + ## Next Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment. diff --git a/docs/quiz-revamp/desktop-question.png b/docs/quiz-revamp/desktop-question.png new file mode 100644 index 0000000..7030246 Binary files /dev/null and b/docs/quiz-revamp/desktop-question.png differ diff --git a/docs/quiz-revamp/mobile-question.png b/docs/quiz-revamp/mobile-question.png new file mode 100644 index 0000000..f9414f6 Binary files /dev/null and b/docs/quiz-revamp/mobile-question.png differ diff --git a/docs/quiz-revamp/study-feedback.png b/docs/quiz-revamp/study-feedback.png new file mode 100644 index 0000000..8acbfa0 Binary files /dev/null and b/docs/quiz-revamp/study-feedback.png differ diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css new file mode 100644 index 0000000..abba534 --- /dev/null +++ b/frontend/src/components/QuizTools.css @@ -0,0 +1,36 @@ +.quiz-tool-dialog { margin: auto; width: min(760px, calc(100vw - 24px)); max-height: 88vh; padding: 0; border: 1px solid #d9dfe8; border-radius: 6px; background: #fff; color: #333; box-shadow: 0 12px 48px #18243640; } +.quiz-tool-dialog::backdrop { background: #172b464d; } +.quiz-tool-dialog:has(.quiz-calculator-keys) { width: min(520px, calc(100vw - 24px)); } +.quiz-tool-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; background: #eff0f6; border-bottom: 1px solid #dfe3eb; } +.quiz-tool-dialog h2 { margin: 0; font-size: 1.2rem; } +.quiz-tool-dialog > header button { border: 0; background: none; font-size: 1.7rem; padding: 0 8px; cursor: pointer; } +.quiz-tool-body { padding: 20px; overflow: auto; } +.quiz-tool-body p { margin: 10px 0; } +.quiz-tool-body button { padding: 8px 12px; border: 1px solid #d7dce6; border-radius: 4px; background: #f7f8fa; color: #303640; cursor: pointer; font: inherit; } +.quiz-tool-body button:disabled { opacity: .5; cursor: not-allowed; } +.quiz-tool-body button:focus-visible, .quiz-tool-body input:focus-visible { outline: 2px solid #426da7; outline-offset: 2px; } +.quiz-tool-body input:not([type=checkbox]) { display: block; width: 100%; padding: 10px; border: 1px solid #cdd4df; border-radius: 4px; font: inherit; color: #333; background: white; } +.quiz-tool-body table { width: 100%; border-collapse: collapse; text-align: left; font-size: .9rem; } +.quiz-tool-body th, .quiz-tool-body td { padding: 12px; vertical-align: top; border-bottom: 1px solid #e2e8ef; } +.quiz-tool-body thead { background: #edf5fb; } +.quiz-tool-body tbody tr:nth-child(even) { background: #edf5fb; } +.quiz-tool-body small { display: block; margin-top: 4px; font-size: .78rem; color: #5d6776; font-weight: normal; } +.quiz-tool-body a { color: #38669d; } +.quiz-tool-body [role=alert] { color: #9c2639; } +.quiz-tool-body input.quiz-calculation { font-size: 1.8rem; text-align: right; margin-bottom: 12px; } +.quiz-calculator-keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.quiz-calculator-keys button { min-height: 48px; font-size: 1.2rem; font-weight: 600; } +.quiz-calculator-keys button:last-child { grid-column: 1 / -1; } +.quiz-calculator-keys .quiz-equals, .quiz-tool-tabs button[aria-pressed=true] { background: #496fa5; color: white; } +.quiz-tool-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0; } +.quiz-reference-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; } +.quiz-reference-controls label { flex: 1; min-width: 150px; } +.quiz-reference-note { color: #5d6776; font-size: .83rem; } +.quiz-reference-scroll { overflow-x: auto; max-height: 45vh; } +.quiz-reference-scroll table { min-width: 450px; } +.quiz-reference-form { margin-top: 20px; padding-top: 16px; border-top: 1px solid #d7dce6; display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.quiz-reference-form h3, .quiz-reference-form > div, .quiz-reference-form > .quiz-check { grid-column: 1 / -1; } +.quiz-check { display: flex; align-items: baseline; gap: 8px; margin: 10px 0; } +.quiz-check input { width: auto; } +.quiz-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 520px) { .quiz-tool-body { padding: 14px; } .quiz-reference-form { grid-template-columns: minmax(0, 1fr); } } diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx new file mode 100644 index 0000000..830bfd0 --- /dev/null +++ b/frontend/src/components/QuizTools.jsx @@ -0,0 +1,132 @@ +import { useEffect, useId, useRef, useState } from 'react' +import api from '../api/client' +import { useAuth } from '../context/AuthContext' +import { calculate } from '../utils/calculator' +import './QuizTools.css' + +export function QuizDialog({ title, children, onClose }) { + const ref = useRef(null) + const titleId = useId() + useEffect(() => { + const dialog = ref.current + dialog.showModal() + return () => { if (dialog.open) dialog.close() } + }, []) + return +} + +function Calculator() { + const [expression, setExpression] = useState('') + const [error, setError] = useState('') + const evaluate = e => { + e?.preventDefault() + try { + setExpression(calculate(expression).toLocaleString('en-US', { useGrouping: false, maximumSignificantDigits: 12 })) + setError('') + } catch (err) { setError(err.message) } + } + const press = key => { + if (key === '=') return evaluate() + setError('') + setExpression(value => key === 'AC' ? '' : key === '⌫' ? value.slice(0, -1) : value + key) + } + return
+} + +const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', is_published: false } +const labFields = [ + ['name', 'Test name', 120], ['group', 'Group', 60], ['reference_range', 'Reference range', 250], + ['units', 'Units', 80], ['age_group', 'Age / population', 120], ['specimen', 'Specimen', 120], + ['source', 'Source citation', 500], ['source_url', 'Source URL (optional)', 2000], +] + +function LabValues() { + const { user } = useAuth() + const isEducator = ['admin', 'moderator'].includes(user?.role) + const [rows, setRows] = useState([]) + const [query, setQuery] = useState('') + const [group, setGroup] = useState('All') + const [manage, setManage] = useState(false) + const [form, setForm] = useState(null) + const [removeId, setRemoveId] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [refresh, setRefresh] = useState(0) + useEffect(() => { + let active = true + setLoading(true) + setError('') + api.get('/study-tools/lab-values', { params: { include_drafts: manage } }).then(r => { if (active) setRows(r.data) }) + .catch(() => { if (active) setError('Could not load reference values. Try again.') }) + .finally(() => { if (active) setLoading(false) }) + return () => { active = false } + }, [manage, refresh]) + const save = async event => { + event.preventDefault() + setSaving(true); setError('') + const payload = Object.fromEntries(Object.keys(emptyReference).map(key => [key, form[key]])) + payload.source_url ||= null + try { + if (form.id) await api.put(`/study-tools/lab-values/${form.id}`, payload) + else await api.post('/study-tools/lab-values', payload) + setForm(null); setRefresh(v => v + 1) + } catch { setError('Could not save. Check all fields and the source URL.') } + finally { setSaving(false) } + } + const remove = async id => { + setSaving(true); setError('') + try { await api.delete(`/study-tools/lab-values/${id}`); setRemoveId(null); setRefresh(v => v + 1) } + catch { setError('Could not delete this reference.') } + finally { setSaving(false) } + } + const filtered = rows.filter(row => (group === 'All' || row.group === group) && + [row.name, row.age_group, row.specimen, row.units].some(value => value.toLowerCase().includes(query.toLowerCase()))) + return <> +Ranges vary with age, laboratory and method. Use the source and population shown; follow local laboratory intervals.
+ {isEducator && } + {error &&{error}
} +Loading references…
: filtered.length === 0 ?No published reference values match. Educators can add sourced, age-specific entries; no ranges are assumed.
: +| Test / specimen | Reference range | Population / source | {manage &&Actions | }
|---|---|---|---|
| {row.name}{row.specimen}{!row.is_published && ' · Draft'} | +{row.reference_range} {row.units} | +{row.age_group}{row.source_url ? {row.source} : row.source} | + {manage &&{removeId === row.id ? <> + + > : } | } +
Shortcuts pause while typing or using a dialog.
+| Action | Key |
|---|---|
| {action} | {key} |