diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index ae8934a..15aa733 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -11,6 +11,7 @@ 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.question_category import QuestionCategory, QuestionCategoryLink from app.models.quiz import Quiz from app.models.user import User from app.utils.auth import get_current_user, require_moderator @@ -101,6 +102,44 @@ def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User = db.commit() +@router.get("/performance-by-category") +def performance_by_category(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Accuracy per category from the user's completed, non-expired general-bank answers. + A question counts in its primary category and every additional category link.""" + from collections import defaultdict + from app.models.question_category import QuestionCategoryLink + rows = db.query(AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id).join( + QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).join( + Question, Question.id == AttemptAnswer.question_id).filter( + QuizAttempt.user_id == user.id, QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), Quiz.course_id.is_(None), + ).all() + extra: dict[int, set[int]] = defaultdict(set) + for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all(): + extra[qid].add(cid) + counts: dict[int, list[int]] = defaultdict(lambda: [0, 0]) + for qid, is_correct, primary in rows: + categories = extra.get(qid, set()) | ({primary} if primary else set()) + for cid in categories: + counts[cid][0] += 1 + if is_correct: + counts[cid][1] += 1 + names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()} + categories = [{ + "category_id": cid, + "name": names.get(cid, "Uncategorized"), + "answered": answered, + "correct": correct, + "accuracy": round(100 * correct / answered, 1) if answered else 0, + } for cid, (answered, correct) in counts.items()] + categories.sort(key=lambda row: (-row["answered"], row["name"])) + return { + "total_answered": sum(row["answered"] for row in categories), + "categories": categories, + "basis": "Your completed, non-expired general test answers; a question counts in every category it belongs to.", + } + + @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() diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index 2f14f3d..f52cb64 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -151,6 +151,24 @@ class StudyToolTests(unittest.TestCase): response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json() self.assertEqual(response['sample_size'], 0) + def test_performance_by_category_expands_links_and_excludes_irrelevant(self): + quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id'] + self.bank.answer(1, True, quiz_id=quiz) + self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded. + quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id'] + self.bank.answer(2, False, quiz_id=quiz2) + self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Owner edits own question. + quiz3 = self.bank.generate(category_ids=[3], count=1).json()['id'] # Private owner test: own attempts still count. + self.bank.answer(3, True, quiz_id=quiz3) + self.bank.answer(5, True, quiz_id=2) # Course quiz excluded. + data = self.client.get('/study-tools/performance-by-category').json() + by_id = {row['category_id']: row for row in data['categories']} + self.assertEqual([by_id[1]['answered'], by_id[1]['correct'], by_id[1]['accuracy']], [1, 1, 100.0]) + self.assertEqual([by_id[2]['answered'], by_id[2]['correct'], by_id[2]['accuracy']], [2, 1, 50.0]) + self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1]) + self.assertEqual(data['total_answered'], 4) + self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first. + 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') diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx new file mode 100644 index 0000000..8ec2bf6 --- /dev/null +++ b/frontend/src/components/CategoryPerformance.jsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' + +export default function CategoryPerformance() { + const [data, setData] = useState(null) + useEffect(() => { + api.get('/study-tools/performance-by-category') + .then(res => setData(res.data)) + .catch(() => setData(null)) + }, []) + if (!data || !data.categories?.length) return null + return ( +
{data.basis}
+ {data.categories.map(category => ( +