feat: performance by category dashboard
Accuracy per category from completed non-expired general-bank answers, counting each question in its primary and additional categories. 56 backend and 90 frontend tests pass.
This commit is contained in:
parent
cdb1ab7468
commit
01337c8c25
6 changed files with 133 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ from app.database import get_db
|
||||||
from app.models.attempt import AttemptAnswer, QuizAttempt
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
||||||
from app.models.lab_reference import LabReference
|
from app.models.lab_reference import LabReference
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
|
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||||
from app.models.quiz import Quiz
|
from app.models.quiz import Quiz
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.utils.auth import get_current_user, require_moderator
|
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()
|
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")
|
@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)):
|
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()
|
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,24 @@ class StudyToolTests(unittest.TestCase):
|
||||||
response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json()
|
response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json()
|
||||||
self.assertEqual(response['sample_size'], 0)
|
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):
|
def test_lab_reference_permissions_validation_and_publication(self):
|
||||||
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
|
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')
|
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')
|
||||||
|
|
|
||||||
26
frontend/src/components/CategoryPerformance.jsx
Normal file
26
frontend/src/components/CategoryPerformance.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card" data-testid="category-performance">
|
||||||
|
<h2 style={{ margin: '0 0 4px' }}>Performance by category</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: '0 0 14px' }}>{data.basis}</p>
|
||||||
|
{data.categories.map(category => (
|
||||||
|
<div className="category-performance-row" key={category.category_id}>
|
||||||
|
<span className="cp-name">{category.name}</span>
|
||||||
|
<span className="cp-track"><span className="cp-fill" style={{ width: `${category.accuracy}%` }} /></span>
|
||||||
|
<span className="cp-accuracy">{category.accuracy}%</span>
|
||||||
|
<span className="cp-count">{category.correct}/{category.answered}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
frontend/src/components/CategoryPerformance.test.jsx
Normal file
36
frontend/src/components/CategoryPerformance.test.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import CategoryPerformance from './CategoryPerformance'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
|
||||||
|
|
||||||
|
beforeEach(() => { vi.resetAllMocks() })
|
||||||
|
|
||||||
|
describe('category performance', () => {
|
||||||
|
it('renders accuracy rows with counts and the stated basis', async () => {
|
||||||
|
api.get.mockResolvedValue({ data: {
|
||||||
|
total_answered: 3,
|
||||||
|
basis: 'Your completed, non-expired general test answers.',
|
||||||
|
categories: [
|
||||||
|
{ category_id: 2, name: 'Clinical reasoning', answered: 2, correct: 1, accuracy: 50 },
|
||||||
|
{ category_id: 1, name: 'Pediatrics', answered: 1, correct: 1, accuracy: 100 },
|
||||||
|
],
|
||||||
|
} })
|
||||||
|
render(<CategoryPerformance />)
|
||||||
|
expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Clinical reasoning')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1/2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/completed, non-expired/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing without category data', async () => {
|
||||||
|
api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } })
|
||||||
|
const { container } = render(<CategoryPerformance />)
|
||||||
|
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(container.querySelector('[data-testid="category-performance"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -735,3 +735,14 @@ body {
|
||||||
.lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
|
.lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
|
||||||
.lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; }
|
.lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; }
|
||||||
.lesson-content strong { font-weight: 700; }
|
.lesson-content strong { font-weight: 700; }
|
||||||
|
.category-performance-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(80px, 2fr) 64px 84px; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .86rem; }
|
||||||
|
.category-performance-row:last-child { border-bottom: none; }
|
||||||
|
.cp-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cp-track { height: 8px; background: var(--bg, #e2e8f0); border-radius: 999px; overflow: hidden; display: block; }
|
||||||
|
.cp-fill { display: block; height: 100%; background: var(--primary, #2563eb); border-radius: 999px; }
|
||||||
|
.cp-accuracy { font-weight: 700; text-align: right; }
|
||||||
|
.cp-count { color: var(--text-muted); text-align: right; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.category-performance-row { grid-template-columns: 1fr 56px; }
|
||||||
|
.cp-track { grid-column: 1 / -1; grid-row: 2; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import LineChart from '../components/LineChart'
|
import LineChart from '../components/LineChart'
|
||||||
import InProgressQuizzes from '../components/InProgressQuizzes'
|
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||||
|
import CategoryPerformance from '../components/CategoryPerformance'
|
||||||
import MyNote from '../components/MyNote'
|
import MyNote from '../components/MyNote'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
|
@ -78,6 +79,8 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
<InProgressQuizzes />
|
<InProgressQuizzes />
|
||||||
|
|
||||||
|
<CategoryPerformance />
|
||||||
|
|
||||||
<MyNote variant="card" />
|
<MyNote variant="card" />
|
||||||
|
|
||||||
{/* Performance graph with dropdown */}
|
{/* Performance graph with dropdown */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue