feat: analysis recommendations, category grants, compact question header
Analysis / recommendations (AMBOSS parity, verified on next.amboss.com): - GET /study-tools/recommendations ranks focus areas by the study time most likely to raise the score. Readiness is the learner's accuracy in a category shrunk toward their own overall accuracy in proportion to sample size, so two unlucky answers do not read as a knowledge gap; it unlocks after 40 answers. Relevance is the share of the bank a category holds. Counts roll up through the category tree, so a system inherits its children's questions. It is deliberately not called EPC and does not claim to predict an exam. - New /analysis page: Performance and Recommendations tabs, readiness summary, adaptive-session box, and expandable focus rows showing questions seen, answered correctly, the linked article and a per-topic practice action. Per-category educator grants: - category_grants table (migration p8b9c0d1e253) plus utils/category_grants.py resolving a grant to the category and all of its descendants. - Question create, edit, delete, bulk and the manager summary now accept a moderator OR an educator granted the affected categories, and refuse moves that would push a question out of the holder's scope. Summary counts are scoped to the grant. - Moderator endpoints to list, add and revoke grants, plus /my-grants driving the nav link and the manager's scope banner; grantable-users avoids handing moderators the admin-only user list. - GrantsPanel in the question manager: grant, list and revoke with inline confirmation. Question page: - The category trail was a fixed 78px band that wrapped into several rows and pushed the stem down the page, followed by three more stacked strips. It is now one scrollable meta line (breadcrumb + difficulty + type) and a single AMBOSS-style action bar (Mark / Listen / Listen through / Clear) between the stem and the options. Difficulty is exposed on the runner payload. Deploy fix: index.html shipped with no cache header, so browsers kept serving the previous bundle references and a release looked like nothing had changed. nginx now sends no-cache for HTML and immutable long-cache for hashed assets. Tests: 16 new backend (recommendation shrinkage, roll-up, locking, grant scope across create/edit/delete/bulk/summary, moderator gate) and 10 new frontend. Full suites green: 88 backend, 116 frontend, build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
This commit is contained in:
parent
741ed9ba30
commit
16dc431066
24 changed files with 1486 additions and 50 deletions
30
backend/alembic/versions/p8b9c0d1e253_category_grants.py
Normal file
30
backend/alembic/versions/p8b9c0d1e253_category_grants.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Per-category editorial grants for non-moderator educators.
|
||||
|
||||
Revision ID: p8b9c0d1e253
|
||||
Revises: n7a8b9c0d142
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "p8b9c0d1e253"
|
||||
down_revision = "n7a8b9c0d142"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS category_grants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL REFERENCES question_categories(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_category_grant UNIQUE (category_id, user_id)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_category_grants_category_id ON category_grants(category_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_category_grants_user_id ON category_grants(user_id)")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS category_grants")
|
||||
|
|
@ -168,6 +168,7 @@ def setup_pgvector():
|
|||
# Import new models so create_all picks them up
|
||||
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
|
||||
from app.models import flashcard, course # noqa
|
||||
from app.models import category_grant # noqa
|
||||
|
||||
# Kill stale idle-in-transaction connections from previous killed startups.
|
||||
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
|
||||
|
|
|
|||
22
backend/app/models/category_grant.py
Normal file
22
backend/app/models/category_grant.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CategoryGrant(Base):
|
||||
"""Lets a non-moderator edit the questions inside one category and its descendants.
|
||||
|
||||
A grant is scoped editorial access, not a role: the holder can create, edit
|
||||
and delete questions filed under the granted category, and nothing else.
|
||||
"""
|
||||
|
||||
__tablename__ = "category_grants"
|
||||
__table_args__ = (UniqueConstraint("category_id", "user_id", name="uq_category_grant"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
granted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
|
@ -6,11 +6,13 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.database import get_db
|
||||
from app.models.question import Question
|
||||
from app.models.category_grant import CategoryGrant
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.user import User
|
||||
from app.services.quiz_builder import (bank_query, filtered_bank_query, category_descendants, category_breadcrumbs,
|
||||
validate_parent, GenerateTestRequest, generate_test, bank_question_predicate)
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
from app.utils.category_grants import manageable_categories
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -65,6 +67,110 @@ def _category_totals(db, user, by_id):
|
|||
return {cid: len(question_set) for cid, question_set in totals.items()}
|
||||
|
||||
|
||||
class GrantIn(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
def _grant_json(grant, users, categories):
|
||||
user = users.get(grant.user_id)
|
||||
return {
|
||||
"id": grant.id,
|
||||
"category_id": grant.category_id,
|
||||
"category_name": categories.get(grant.category_id),
|
||||
"user_id": grant.user_id,
|
||||
"user_name": user.name if user else None,
|
||||
"user_email": user.email if user else None,
|
||||
"created_at": grant.created_at.isoformat() if grant.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grants")
|
||||
def list_category_grants(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Every per-category editorial grant, for the question-manager admin panel."""
|
||||
grants = db.query(CategoryGrant).order_by(CategoryGrant.id).all()
|
||||
categories = {cat.id: cat.name for cat in db.query(QuestionCategory).all()}
|
||||
users = {user.id: user for user in db.query(User).filter(
|
||||
User.id.in_({g.user_id for g in grants}))} if grants else {}
|
||||
return [_grant_json(grant, users, categories) for grant in grants]
|
||||
|
||||
|
||||
@router.get("/grantable-users")
|
||||
def grantable_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Users a grant can be given to — moderators already manage everything."""
|
||||
rows = db.query(User.id, User.name, User.email, User.role).order_by(User.name).all()
|
||||
return [{"id": uid, "name": name, "email": email}
|
||||
for uid, name, email, role in rows if role not in ("admin", "moderator")]
|
||||
|
||||
|
||||
@router.post("/{cat_id}/grants", status_code=201)
|
||||
def add_category_grant(
|
||||
cat_id: int,
|
||||
data: GrantIn,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Let one user edit the questions in this category and everything under it."""
|
||||
category = db.get(QuestionCategory, cat_id)
|
||||
if not category:
|
||||
raise HTTPException(404, "Category not found")
|
||||
user = db.get(User, data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
if user.is_moderator:
|
||||
raise HTTPException(400, "Moderators already manage every category")
|
||||
existing = db.query(CategoryGrant).filter_by(category_id=cat_id, user_id=data.user_id).first()
|
||||
if existing:
|
||||
raise HTTPException(409, "This user already has a grant for this category")
|
||||
grant = CategoryGrant(category_id=cat_id, user_id=data.user_id, granted_by=current_user.id)
|
||||
db.add(grant)
|
||||
db.commit()
|
||||
db.refresh(grant)
|
||||
return _grant_json(grant, {user.id: user}, {category.id: category.name})
|
||||
|
||||
|
||||
@router.delete("/{cat_id}/grants/{user_id}", status_code=204)
|
||||
def remove_category_grant(
|
||||
cat_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
grant = db.query(CategoryGrant).filter_by(category_id=cat_id, user_id=user_id).first()
|
||||
if not grant:
|
||||
raise HTTPException(404, "Grant not found")
|
||||
db.delete(grant)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/my-grants")
|
||||
def my_category_grants(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""What the signed-in user may edit — drives the question-manager entry point."""
|
||||
scope = manageable_categories(db, current_user)
|
||||
if scope is None:
|
||||
return {"is_moderator": True, "can_manage_questions": True, "categories": []}
|
||||
names = {cat.id: cat.name for cat in db.query(QuestionCategory).filter(
|
||||
QuestionCategory.id.in_(scope))} if scope else {}
|
||||
granted = {row[0] for row in db.query(CategoryGrant.category_id).filter(
|
||||
CategoryGrant.user_id == current_user.id).all()}
|
||||
return {
|
||||
"is_moderator": False,
|
||||
"can_manage_questions": bool(scope),
|
||||
"categories": sorted(
|
||||
({"id": cid, "name": names.get(cid), "direct": cid in granted} for cid in scope),
|
||||
key=lambda row: (row["name"] or ""),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[QCatResponse])
|
||||
def list_question_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
cats = db.query(QuestionCategory).order_by(QuestionCategory.name).all()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ from app.models.favorite import Favorite
|
|||
from app.services.quiz_builder import (bank_query, category_descendants, filtered_bank_query,
|
||||
CreateFromBankRequest, GenerateTestRequest, create_saved_test, generate_test)
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
from app.utils.category_grants import (assert_can_manage_category, assert_can_manage_questions,
|
||||
is_question_manager, manageable_categories, question_in_scope, require_question_manager)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -51,7 +53,9 @@ def delete_question(
|
|||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
is_mod = current_user.role in ("admin", "moderator")
|
||||
if not is_mod:
|
||||
scope = manageable_categories(db, current_user)
|
||||
granted = scope is not None and bool(scope) and question_in_scope(db, scope, question)
|
||||
if not is_mod and not granted:
|
||||
if question.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this question")
|
||||
# Regular users can only delete questions they created (no source quiz)
|
||||
|
|
@ -81,12 +85,19 @@ def edit_question(
|
|||
question_id: int,
|
||||
data: QuestionEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Edit a question. Only admins and educators manage questions."""
|
||||
"""Edit a question — moderators, or an educator granted its category."""
|
||||
scope = require_question_manager(db, current_user)
|
||||
question = db.query(Question).filter(Question.id == question_id).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
assert_can_manage_questions(db, scope, [question_id])
|
||||
if "question_category_id" in data.model_fields_set:
|
||||
# Moving a question out of your scope would put it beyond your reach.
|
||||
assert_can_manage_category(scope, data.question_category_id)
|
||||
for extra_id in (data.additional_category_ids or []):
|
||||
assert_can_manage_category(scope, extra_id)
|
||||
values = validate_image_attachments(db, current_user, data.model_dump(exclude_unset=True))
|
||||
if values.get("question_category_id") is not None and not db.get(QuestionCategory, values["question_category_id"]):
|
||||
raise HTTPException(400, "Category not found")
|
||||
|
|
@ -427,9 +438,11 @@ class ManualQuestionCreate(BaseModel):
|
|||
def create_question_manually(
|
||||
data: ManualQuestionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a single question manually (not from PDF extraction)."""
|
||||
"""Create a single question manually — moderators, or an educator in their categories."""
|
||||
scope = require_question_manager(db, current_user)
|
||||
assert_can_manage_category(scope, data.question_category_id)
|
||||
q_text = data.question_text.strip()
|
||||
if not q_text:
|
||||
raise HTTPException(status_code=400, detail="Question text is required")
|
||||
|
|
@ -582,14 +595,18 @@ class BulkQuestionAction(BaseModel):
|
|||
def bulk_question_action(
|
||||
data: BulkQuestionAction,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Apply one editorial action to up to 500 checked questions."""
|
||||
scope = require_question_manager(db, current_user)
|
||||
ids = list(dict.fromkeys(data.question_ids))
|
||||
if not ids:
|
||||
raise HTTPException(400, "No questions selected")
|
||||
if len(ids) > 500:
|
||||
raise HTTPException(400, "Select at most 500 questions per action")
|
||||
assert_can_manage_questions(db, scope, ids)
|
||||
if data.action == "category":
|
||||
assert_can_manage_category(scope, data.category_id)
|
||||
|
||||
rows = db.query(Question).filter(Question.id.in_(ids))
|
||||
if data.action == "category":
|
||||
|
|
@ -612,10 +629,20 @@ def bulk_question_action(
|
|||
@router.get("/manage/summary")
|
||||
def question_manager_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Editorial health counters shown as one-click filters in the question manager."""
|
||||
"""Editorial health counters shown as one-click filters in the question manager.
|
||||
|
||||
A granted educator sees the counts for their own categories only.
|
||||
"""
|
||||
scope = require_question_manager(db, current_user)
|
||||
base = db.query(func.count(Question.id))
|
||||
if scope is not None:
|
||||
base = base.filter(or_(
|
||||
Question.question_category_id.in_(scope),
|
||||
Question.id.in_(select(QuestionCategoryLink.question_id).where(
|
||||
QuestionCategoryLink.category_id.in_(scope))),
|
||||
))
|
||||
blank = ("", " ")
|
||||
return {
|
||||
"total": base.scalar() or 0,
|
||||
|
|
@ -625,6 +652,7 @@ def question_manager_summary(
|
|||
or_(Question.explanation.is_(None), Question.explanation.in_(blank))).scalar() or 0,
|
||||
"private": base.filter(Question.is_shared == 0).scalar() or 0,
|
||||
"mine": base.filter(Question.user_id == current_user.id).scalar() or 0,
|
||||
"scoped": scope is not None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"""Educator-maintained lab references and authorized question response statistics."""
|
||||
from collections import Counter
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -17,6 +18,7 @@ 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
|
||||
from app.services.quiz_builder import shareable_question_predicate
|
||||
from app.utils.quiz_access import general_quiz_visibility, require_quiz_access
|
||||
from app.utils.quiz_questions import question_in_quiz
|
||||
|
||||
|
|
@ -208,6 +210,170 @@ def performance_by_category(db: Session = Depends(get_db), user: User = Depends(
|
|||
}
|
||||
|
||||
|
||||
# Readiness needs enough answers before a per-category estimate means anything.
|
||||
READINESS_UNLOCK_ANSWERS = 40
|
||||
# Shrinkage weight: a category with this many answers sits halfway between its
|
||||
# own accuracy and the learner's overall accuracy.
|
||||
READINESS_PRIOR_ANSWERS = 8
|
||||
|
||||
|
||||
def _category_rollup(categories):
|
||||
"""Map every category to itself plus all of its ancestors, for roll-up counting."""
|
||||
parents = {cat.id: cat.parent_id for cat in categories}
|
||||
ancestry: dict[int, list[int]] = {}
|
||||
for cid in parents:
|
||||
chain, cursor, guard = [], cid, 0
|
||||
while cursor is not None and guard < 12:
|
||||
chain.append(cursor)
|
||||
cursor = parents.get(cursor)
|
||||
guard += 1
|
||||
ancestry[cid] = chain
|
||||
return ancestry
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
def study_recommendations(
|
||||
group: Literal["systems", "subtopics"] = "systems",
|
||||
limit: int = Query(20, ge=1, le=60),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Focus areas ranked by the study time most likely to raise the learner's score.
|
||||
|
||||
Readiness is the learner's accuracy in a category shrunk toward their own
|
||||
overall accuracy in proportion to how few answers that category has, so a
|
||||
single unlucky question does not read as a knowledge gap. It is a plain
|
||||
empirical-Bayes estimate over recorded answers — not a psychometric exam
|
||||
score, and not a prediction of any real examination.
|
||||
"""
|
||||
categories = db.query(QuestionCategory).all()
|
||||
ancestry = _category_rollup(categories)
|
||||
names = {cat.id: cat.name for cat in categories}
|
||||
parents = {cat.id: cat.parent_id for cat in categories}
|
||||
top_level = {cat.id for cat in categories if cat.parent_id is None}
|
||||
|
||||
# ── What the learner has answered ──────────────────────────────
|
||||
answered_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_links: dict[int, set[int]] = defaultdict(set)
|
||||
for question_id, category_id in db.query(
|
||||
QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
|
||||
extra_links[question_id].add(category_id)
|
||||
|
||||
def categories_for(question_id, primary):
|
||||
direct = extra_links.get(question_id, set()) | ({primary} if primary else set())
|
||||
rolled = set()
|
||||
for cid in direct:
|
||||
rolled.update(ancestry.get(cid, [cid]))
|
||||
return rolled
|
||||
|
||||
answered: dict[int, int] = defaultdict(int)
|
||||
correct: dict[int, int] = defaultdict(int)
|
||||
seen_questions: dict[int, set[int]] = defaultdict(set)
|
||||
total_answers = len(answered_rows)
|
||||
total_correct = sum(1 for _, is_correct, _ in answered_rows if is_correct)
|
||||
for question_id, is_correct, primary in answered_rows:
|
||||
for cid in categories_for(question_id, primary):
|
||||
answered[cid] += 1
|
||||
seen_questions[cid].add(question_id)
|
||||
if is_correct:
|
||||
correct[cid] += 1
|
||||
|
||||
# ── How much bank material each category holds ─────────────────
|
||||
available: dict[int, int] = defaultdict(int)
|
||||
bank_total = 0
|
||||
for question_id, primary in db.query(Question.id, Question.question_category_id).filter(
|
||||
shareable_question_predicate()).all():
|
||||
bank_total += 1
|
||||
for cid in categories_for(question_id, primary):
|
||||
available[cid] += 1
|
||||
|
||||
articles = {}
|
||||
for article in db.query(Article).filter(Article.status == "published",
|
||||
Article.category_id.isnot(None)).all():
|
||||
articles.setdefault(article.category_id, article)
|
||||
|
||||
overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
|
||||
unlocked = total_answers >= READINESS_UNLOCK_ANSWERS
|
||||
|
||||
scope = top_level if group == "systems" else {cat.id for cat in categories if cat.parent_id is not None}
|
||||
rows = []
|
||||
for cid in scope:
|
||||
seen = len(seen_questions.get(cid, ()))
|
||||
pool = available.get(cid, 0)
|
||||
if pool == 0 and seen == 0:
|
||||
continue
|
||||
n = answered.get(cid, 0)
|
||||
c = correct.get(cid, 0)
|
||||
accuracy = round(100 * c / n, 1) if n else None
|
||||
readiness = None
|
||||
if unlocked and n:
|
||||
shrunk = (c + READINESS_PRIOR_ANSWERS * overall_accuracy) / (n + READINESS_PRIOR_ANSWERS)
|
||||
readiness = round(100 * shrunk, 1)
|
||||
relevance = round(100 * pool / bank_total, 1) if bank_total else 0.0
|
||||
coverage = round(100 * seen / pool, 1) if pool else 0.0
|
||||
article = articles.get(cid)
|
||||
rows.append({
|
||||
"category_id": cid,
|
||||
"name": names.get(cid, "Uncategorized"),
|
||||
"parent_id": parents.get(cid),
|
||||
"parent_name": names.get(parents.get(cid)),
|
||||
"answered": n,
|
||||
"correct": c,
|
||||
"seen_questions": seen,
|
||||
"available": pool,
|
||||
"coverage": coverage,
|
||||
"accuracy": accuracy,
|
||||
"readiness": readiness,
|
||||
"relevance": relevance,
|
||||
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
|
||||
"article_id": article.id if article else None,
|
||||
"article_title": article.title if article else None,
|
||||
})
|
||||
|
||||
# Priority: weak-and-relevant first, then untouched material by relevance.
|
||||
baseline = 100 * overall_accuracy if total_answers else 70.0
|
||||
for row in rows:
|
||||
score = row["readiness"] if row["readiness"] is not None else row["accuracy"]
|
||||
gap = (baseline - score) / 100 if score is not None else 0.5 # unseen material sits mid-priority
|
||||
unseen = 1 - (row["coverage"] / 100)
|
||||
row["priority"] = round(max(gap, 0.0) * (row["relevance"] / 100) + 0.25 * unseen * (row["relevance"] / 100), 5)
|
||||
rows.sort(key=lambda row: (-row["priority"], -row["relevance"], row["name"]))
|
||||
focus_ids = {row["category_id"] for row in rows[:3] if row["answered"]}
|
||||
for row in rows:
|
||||
if row["category_id"] in focus_ids:
|
||||
row["is_focus_area"] = True
|
||||
else:
|
||||
row["is_focus_area"] = False
|
||||
|
||||
return {
|
||||
"group": group,
|
||||
"unlocked": unlocked,
|
||||
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total_answers),
|
||||
"total_answered": total_answers,
|
||||
"unique_questions_seen": len({row[0] for row in answered_rows}),
|
||||
"bank_total": bank_total,
|
||||
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
|
||||
"focus_areas": rows[:limit],
|
||||
"basis": (
|
||||
"Your completed, non-expired general-bank answers, rolled up through the category tree. "
|
||||
f"Readiness shrinks each category's accuracy toward your overall {round(100 * overall_accuracy)}% "
|
||||
"so small samples do not overstate a gap; it unlocks after "
|
||||
f"{READINESS_UNLOCK_ANSWERS} answers. Relevance is the share of the question bank a category holds. "
|
||||
"These are study hints from your own answers, not an exam score prediction."
|
||||
),
|
||||
}
|
||||
|
||||
@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()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class QuestionResponse(BaseModel):
|
|||
options: list[str] | None
|
||||
image_path: str | None = None
|
||||
question_category_id: int | None = None
|
||||
difficulty: str | None = None
|
||||
category_breadcrumbs: list[dict] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
86
backend/app/utils/category_grants.py
Normal file
86
backend/app/utils/category_grants.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Per-category editorial access for non-moderator educators.
|
||||
|
||||
A moderator or admin manages every question. Everyone else manages only the
|
||||
questions filed under a category they hold a grant on, or under any descendant
|
||||
of it. `manageable_categories` returns None to mean "no restriction".
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.category_grant import CategoryGrant
|
||||
from app.models.question import Question
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _descendants(db: Session, roots: set[int]) -> set[int]:
|
||||
"""Expand category ids to include every category beneath them."""
|
||||
if not roots:
|
||||
return set()
|
||||
children: dict[int, list[int]] = {}
|
||||
for cid, parent_id in db.query(QuestionCategory.id, QuestionCategory.parent_id).all():
|
||||
if parent_id is not None:
|
||||
children.setdefault(parent_id, []).append(cid)
|
||||
seen, stack = set(roots), list(roots)
|
||||
while stack:
|
||||
for child in children.get(stack.pop(), ()):
|
||||
if child not in seen:
|
||||
seen.add(child)
|
||||
stack.append(child)
|
||||
return seen
|
||||
|
||||
|
||||
def manageable_categories(db: Session, user: User) -> set[int] | None:
|
||||
"""Category ids this user may edit questions in; None means all of them."""
|
||||
if user.is_moderator:
|
||||
return None
|
||||
roots = {row[0] for row in db.query(CategoryGrant.category_id).filter(
|
||||
CategoryGrant.user_id == user.id).all()}
|
||||
return _descendants(db, roots)
|
||||
|
||||
|
||||
def is_question_manager(db: Session, user: User) -> bool:
|
||||
"""True when the user may edit questions somewhere."""
|
||||
scope = manageable_categories(db, user)
|
||||
return scope is None or bool(scope)
|
||||
|
||||
|
||||
def require_question_manager(db: Session, user: User) -> set[int] | None:
|
||||
"""Gate a question-management route; returns the caller's category scope."""
|
||||
scope = manageable_categories(db, user)
|
||||
if scope is not None and not scope:
|
||||
raise HTTPException(403, "Question management requires moderator access or a category grant")
|
||||
return scope
|
||||
|
||||
|
||||
def assert_can_manage_category(scope: set[int] | None, category_id: int | None, what: str = "category") -> None:
|
||||
if scope is None:
|
||||
return
|
||||
if category_id is None or category_id not in scope:
|
||||
raise HTTPException(403, f"You do not have an editorial grant for this {what}")
|
||||
|
||||
|
||||
def assert_can_manage_questions(db: Session, scope: set[int] | None, question_ids: list[int]) -> None:
|
||||
"""Every target question must sit in a granted category (primary or additional)."""
|
||||
if scope is None or not question_ids:
|
||||
return
|
||||
primary = dict(db.query(Question.id, Question.question_category_id).filter(
|
||||
Question.id.in_(question_ids)).all())
|
||||
extra: dict[int, set[int]] = {}
|
||||
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter(
|
||||
QuestionCategoryLink.question_id.in_(question_ids)).all():
|
||||
extra.setdefault(qid, set()).add(cid)
|
||||
for question_id in question_ids:
|
||||
if question_id not in primary:
|
||||
continue # a missing row is reported as 404 by the caller, not as a permission error
|
||||
owned = ({primary[question_id]} if primary[question_id] else set()) | extra.get(question_id, set())
|
||||
if not (owned & scope):
|
||||
raise HTTPException(403, "You do not have an editorial grant for one of these questions")
|
||||
|
||||
|
||||
def question_in_scope(db: Session, scope: set[int], question: Question) -> bool:
|
||||
"""True when a question sits in a granted category (primary or additional)."""
|
||||
owned = {question.question_category_id} if question.question_category_id else set()
|
||||
owned |= {row[0] for row in db.query(QuestionCategoryLink.category_id).filter(
|
||||
QuestionCategoryLink.question_id == question.id).all()}
|
||||
return bool(owned & scope)
|
||||
188
backend/tests/test_category_grants.py
Normal file
188
backend/tests/test_category_grants.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Per-category editorial grants: a non-moderator educator edits only their categories.
|
||||
|
||||
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
||||
No application startup, external services or AI calls; a disposable SQLite database per test.
|
||||
"""
|
||||
import os
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models.category_grant import CategoryGrant
|
||||
from app.models.question import Question
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.user import User
|
||||
from app.routers import question_categories, questions
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
|
||||
class CategoryGrantTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
|
||||
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
|
||||
self.educator = User(id=2, name="Educator", email="edu@example.test", hashed_password="unused")
|
||||
self.outsider = User(id=3, name="Outsider", email="out@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.mod, self.educator, self.outsider])
|
||||
# Cardiology > Kawasaki disease · Neurology stands alone.
|
||||
self.db.add_all([
|
||||
QuestionCategory(id=1, name="Cardiology", user_id=1),
|
||||
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=1),
|
||||
QuestionCategory(id=10, name="Neurology", user_id=1),
|
||||
])
|
||||
self.db.flush()
|
||||
for qid, category in [(1, 1), (2, 2), (3, 10), (4, None)]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=1, is_shared=1,
|
||||
question_text=f"Question {qid}", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes", explanation="Because"))
|
||||
self.db.commit()
|
||||
|
||||
self.user = self.mod
|
||||
app = FastAPI()
|
||||
app.include_router(questions.router, prefix="/questions")
|
||||
app.include_router(question_categories.router, prefix="/question-categories")
|
||||
app.dependency_overrides[get_db] = lambda: self.db
|
||||
app.dependency_overrides[get_current_user] = lambda: self.user
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def grant(self, category_id=1, user_id=2):
|
||||
self.db.add(CategoryGrant(category_id=category_id, user_id=user_id, granted_by=1))
|
||||
self.db.commit()
|
||||
|
||||
# ── Granting ───────────────────────────────────────────────────
|
||||
def test_moderator_grants_and_revokes_a_category(self):
|
||||
response = self.client.post("/question-categories/1/grants", json={"user_id": 2})
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.assertEqual(response.json()["category_name"], "Cardiology")
|
||||
self.assertEqual(response.json()["user_email"], "edu@example.test")
|
||||
|
||||
listed = self.client.get("/question-categories/grants").json()
|
||||
self.assertEqual([(g["category_id"], g["user_id"]) for g in listed], [(1, 2)])
|
||||
|
||||
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 409)
|
||||
self.assertEqual(self.client.delete("/question-categories/1/grants/2").status_code, 204)
|
||||
self.assertEqual(self.client.get("/question-categories/grants").json(), [])
|
||||
|
||||
def test_grants_are_moderator_only_and_validate_their_target(self):
|
||||
self.assertEqual(self.client.post("/question-categories/999/grants", json={"user_id": 2}).status_code, 404)
|
||||
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 999}).status_code, 404)
|
||||
# A moderator already manages everything; a grant would be meaningless.
|
||||
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 1}).status_code, 400)
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 403)
|
||||
self.assertEqual(self.client.get("/question-categories/grants").status_code, 403)
|
||||
|
||||
def test_my_grants_reports_the_scope_including_descendants(self):
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.get("/question-categories/my-grants").json(),
|
||||
{"is_moderator": False, "can_manage_questions": False, "categories": []})
|
||||
self.grant(category_id=1)
|
||||
scope = self.client.get("/question-categories/my-grants").json()
|
||||
self.assertTrue(scope["can_manage_questions"])
|
||||
self.assertEqual([(c["name"], c["direct"]) for c in scope["categories"]],
|
||||
[("Cardiology", True), ("Kawasaki disease", False)])
|
||||
self.user = self.mod
|
||||
self.assertTrue(self.client.get("/question-categories/my-grants").json()["is_moderator"])
|
||||
|
||||
# ── Using a grant ──────────────────────────────────────────────
|
||||
def test_educator_edits_inside_the_grant_and_its_descendants_only(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
|
||||
# Question 2 sits in the child category, still inside the grant.
|
||||
self.assertEqual(self.client.patch("/questions/2", json={"difficulty": "hard"}).status_code, 200)
|
||||
# Neurology and the uncategorised question are out of scope.
|
||||
self.assertEqual(self.client.patch("/questions/3", json={"explanation": "No"}).status_code, 403)
|
||||
self.assertEqual(self.client.patch("/questions/4", json={"explanation": "No"}).status_code, 403)
|
||||
self.assertEqual(self.db.get(Question, 3).explanation, "Because")
|
||||
|
||||
def test_educator_cannot_move_a_question_out_of_their_scope(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 10}).status_code, 403)
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"additional_category_ids": [10]}).status_code, 403)
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 2}).status_code, 200)
|
||||
self.assertEqual(self.db.get(Question, 1).question_category_id, 2)
|
||||
|
||||
def test_educator_creates_only_inside_their_scope(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
body = {"question_text": "New question", "question_type": "mcq",
|
||||
"options": ["yes", "no"], "correct_answer": "yes"}
|
||||
with patch("app.services.embedding_service.embed_question", return_value=None):
|
||||
allowed = self.client.post("/questions/create", json={**body, "question_category_id": 2})
|
||||
self.assertEqual(allowed.status_code, 200, allowed.text)
|
||||
self.assertEqual(self.client.post("/questions/create",
|
||||
json={**body, "question_category_id": 10}).status_code, 403)
|
||||
# Uncategorised creation is out of scope too — it would land outside the grant.
|
||||
self.assertEqual(self.client.post("/questions/create", json=body).status_code, 403)
|
||||
|
||||
def test_bulk_actions_are_confined_to_the_grant(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
mixed = self.client.post("/questions/bulk",
|
||||
json={"question_ids": [1, 3], "action": "difficulty", "difficulty": "easy"})
|
||||
self.assertEqual(mixed.status_code, 403)
|
||||
self.assertIsNone(self.db.get(Question, 1).difficulty) # nothing applied
|
||||
|
||||
ok = self.client.post("/questions/bulk",
|
||||
json={"question_ids": [1, 2], "action": "difficulty", "difficulty": "easy"})
|
||||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
self.assertEqual(self.db.get(Question, 1).difficulty, "easy")
|
||||
|
||||
# Reassigning into an ungranted category is refused.
|
||||
self.assertEqual(self.client.post("/questions/bulk",
|
||||
json={"question_ids": [1], "action": "category",
|
||||
"category_id": 10}).status_code, 403)
|
||||
|
||||
def test_additional_category_links_bring_a_question_into_scope(self):
|
||||
self.grant(category_id=10)
|
||||
self.db.add(QuestionCategoryLink(question_id=1, category_id=10))
|
||||
self.db.commit()
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
|
||||
|
||||
def test_educator_deletes_only_inside_the_grant(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
self.assertEqual(self.client.delete("/questions/3").status_code, 403)
|
||||
self.assertEqual(self.client.delete("/questions/1").status_code, 204)
|
||||
self.assertIsNone(self.db.get(Question, 1))
|
||||
|
||||
def test_summary_is_scoped_to_the_grant(self):
|
||||
self.grant(category_id=1)
|
||||
self.user = self.educator
|
||||
summary = self.client.get("/questions/manage/summary").json()
|
||||
self.assertTrue(summary["scoped"])
|
||||
self.assertEqual(summary["total"], 2) # questions 1 and 2 only
|
||||
moderator_summary = None
|
||||
self.user = self.mod
|
||||
moderator_summary = self.client.get("/questions/manage/summary").json()
|
||||
self.assertFalse(moderator_summary["scoped"])
|
||||
self.assertEqual(moderator_summary["total"], 4)
|
||||
|
||||
def test_ungranted_user_is_locked_out_of_question_management(self):
|
||||
self.user = self.outsider
|
||||
self.assertEqual(self.client.get("/questions/manage/summary").status_code, 403)
|
||||
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "No"}).status_code, 403)
|
||||
self.assertEqual(self.client.post("/questions/bulk",
|
||||
json={"question_ids": [1], "action": "delete"}).status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
|||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.attempt import AttemptAnswer, QuizAttempt
|
||||
from app.models.course import Course
|
||||
from app.models.question import Question
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
|
|
@ -28,7 +28,8 @@ from app.models.quiz import Quiz
|
|||
from app.models.quiz_category import QuizCategory
|
||||
from app.models.quiz_question_link import QuizQuestionLink
|
||||
from app.models.user import User
|
||||
from app.routers import questions
|
||||
from app.models.article import Article
|
||||
from app.routers import questions, study_tools
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
|
||||
|
|
@ -231,5 +232,115 @@ class QuestionManagerTests(unittest.TestCase):
|
|||
json={"question_ids": [1], "action": "delete"}).status_code, 403)
|
||||
|
||||
|
||||
class RecommendationTests(unittest.TestCase):
|
||||
"""Focus-area ranking: readiness shrinkage, relevance and roll-up through the tree."""
|
||||
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
|
||||
self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.user, User(id=3, name="Mod", email="mod@example.test",
|
||||
hashed_password="unused", role="moderator")])
|
||||
self.db.add_all([
|
||||
QuestionCategory(id=1, name="Cardiology", user_id=3),
|
||||
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=3),
|
||||
QuestionCategory(id=10, name="Neurology", user_id=3),
|
||||
])
|
||||
self.db.add(Quiz(id=1, title="Bank test", user_id=1, is_published=1, questions_count=6))
|
||||
self.db.flush()
|
||||
# 6 shareable bank questions: 4 cardiology (all under the Kawasaki child), 2 neurology.
|
||||
for qid, category in [(1, 2), (2, 2), (3, 2), (4, 2), (5, 10), (6, 10)]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=3, is_shared=1,
|
||||
question_text=f"Question {qid}", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.add(Article(id=7, slug="kawasaki", title="Kawasaki disease", sections=[],
|
||||
category_id=1, user_id=3, status="published"))
|
||||
self.db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(study_tools.router, prefix="/study-tools")
|
||||
app.dependency_overrides[get_db] = lambda: self.db
|
||||
app.dependency_overrides[get_current_user] = lambda: self.user
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def answer(self, question_id, correct):
|
||||
attempt = self.db.query(QuizAttempt).filter_by(user_id=1, quiz_id=1).first()
|
||||
if attempt is None:
|
||||
attempt = QuizAttempt(quiz_id=1, user_id=1, mode="study", total_questions=6,
|
||||
completed_at=datetime(2026, 3, 1))
|
||||
self.db.add(attempt)
|
||||
self.db.flush()
|
||||
self.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question_id,
|
||||
user_answer="yes" if correct else "no", is_correct=correct))
|
||||
self.db.commit()
|
||||
|
||||
def recommend(self, **params):
|
||||
response = self.client.get("/study-tools/recommendations", params=params)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_relevance_and_coverage_roll_up_to_the_parent_system(self):
|
||||
data = self.recommend()
|
||||
rows = {row["name"]: row for row in data["focus_areas"]}
|
||||
# Cardiology owns 4 of 6 bank questions through its child category.
|
||||
self.assertEqual(rows["Cardiology"]["available"], 4)
|
||||
self.assertEqual(rows["Cardiology"]["relevance"], 66.7)
|
||||
self.assertEqual(rows["Neurology"]["available"], 2)
|
||||
self.assertEqual(data["bank_total"], 6)
|
||||
# Systems grouping shows top-level categories only.
|
||||
self.assertNotIn("Kawasaki disease", rows)
|
||||
self.assertIn("Kawasaki disease", {r["name"] for r in self.recommend(group="subtopics")["focus_areas"]})
|
||||
|
||||
def test_readiness_stays_locked_until_enough_answers(self):
|
||||
self.answer(1, False)
|
||||
data = self.recommend()
|
||||
self.assertFalse(data["unlocked"])
|
||||
self.assertEqual(data["answers_needed"], 39)
|
||||
self.assertEqual(data["overall_accuracy"], 0.0) # reported, but the client gates on `unlocked`
|
||||
cardiology = next(r for r in data["focus_areas"] if r["name"] == "Cardiology")
|
||||
self.assertIsNone(cardiology["readiness"])
|
||||
self.assertEqual(cardiology["accuracy"], 0.0) # raw accuracy still reported
|
||||
self.assertEqual(cardiology["status"], "focus")
|
||||
|
||||
def test_readiness_shrinks_a_small_sample_toward_overall_accuracy(self):
|
||||
# 40 answers unlocks readiness: 38 correct in neurology, 2 wrong in cardiology.
|
||||
for _ in range(19):
|
||||
self.answer(5, True)
|
||||
self.answer(6, True)
|
||||
self.answer(1, False)
|
||||
self.answer(2, False)
|
||||
data = self.recommend()
|
||||
self.assertTrue(data["unlocked"])
|
||||
rows = {row["name"]: row for row in data["focus_areas"]}
|
||||
cardiology = rows["Cardiology"]
|
||||
self.assertEqual(cardiology["accuracy"], 0.0)
|
||||
# Two wrong answers alone must not read as 0% knowledge; shrinkage pulls it up
|
||||
# toward the learner's ~95% overall, but it still ranks below neurology.
|
||||
self.assertGreater(cardiology["readiness"], 0.0)
|
||||
self.assertLess(cardiology["readiness"], rows["Neurology"]["readiness"])
|
||||
self.assertGreater(cardiology["priority"], rows["Neurology"]["priority"])
|
||||
self.assertEqual(data["focus_areas"][0]["name"], "Cardiology")
|
||||
|
||||
def test_untouched_categories_report_no_data_and_link_their_article(self):
|
||||
data = self.recommend()
|
||||
cardiology = next(r for r in data["focus_areas"] if r["name"] == "Cardiology")
|
||||
self.assertEqual(cardiology["status"], "no_data")
|
||||
self.assertEqual(cardiology["answered"], 0)
|
||||
self.assertEqual(cardiology["article_id"], 7)
|
||||
self.assertEqual(cardiology["article_title"], "Kawasaki disease")
|
||||
self.assertFalse(cardiology["is_focus_area"]) # focus areas need recorded answers
|
||||
|
||||
def test_grouping_is_validated(self):
|
||||
self.assertEqual(self.client.get("/study-tools/recommendations",
|
||||
params={"group": "nonsense"}).status_code, 422)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -58,8 +58,23 @@ server {
|
|||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Hashed build assets are immutable — a new build gets a new filename.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# index.html must never be cached, or a deploy keeps serving the old
|
||||
# bundle references and the app appears unchanged after a release.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const AccountPage = lazy(() => import('./pages/AccountPage'))
|
|||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
||||
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
|
||||
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
|
||||
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
||||
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
||||
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
|
||||
|
|
@ -93,6 +94,8 @@ function AppRoutes() {
|
|||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/question-bank" element={<QuestionBankPage />} />
|
||||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<Route path="/articles/:id" element={<ArticlePage />} />
|
||||
|
|
@ -111,7 +114,6 @@ function AppRoutes() {
|
|||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
109
frontend/src/components/GrantsPanel.jsx
Normal file
109
frontend/src/components/GrantsPanel.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
const apiError = (err, fallback) => {
|
||||
const detail = err?.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Moderator panel: who may edit questions in which category. */
|
||||
export default function GrantsPanel({ categories }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [grants, setGrants] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [revoking, setRevoking] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get('/question-categories/grants').then(res => setGrants(res.data || [])).catch(() => setGrants([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || grants !== null) return
|
||||
load()
|
||||
api.get('/question-categories/grantable-users').then(res => setUsers(res.data || [])).catch(() => setUsers([]))
|
||||
}, [open, grants, load])
|
||||
|
||||
const add = async () => {
|
||||
if (!categoryId || !userId) { setError('Pick a category and a user'); return }
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post(`/question-categories/${categoryId}/grants`, { user_id: Number(userId) })
|
||||
setGrants(prev => [...(prev || []), res.data])
|
||||
setUserId('')
|
||||
} catch (err) { setError(apiError(err, 'Could not add this grant')) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revoke = async (grant) => {
|
||||
setRevoking(null)
|
||||
try {
|
||||
await api.delete(`/question-categories/${grant.category_id}/grants/${grant.user_id}`)
|
||||
setGrants(prev => prev.filter(g => g.id !== grant.id))
|
||||
} catch (err) { setError(apiError(err, 'Could not revoke this grant')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qm-grants">
|
||||
<button type="button" className="qm-grants-toggle" aria-expanded={open} onClick={() => setOpen(v => !v)}>
|
||||
<span>Educator grants{grants !== null && ` (${grants.length})`}</span>
|
||||
<span aria-hidden="true">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="qm-grants-body">
|
||||
<p className="qm-grants-note">
|
||||
A grant lets one non-moderator edit, create and delete the questions in a category and everything
|
||||
beneath it — nothing else.
|
||||
</p>
|
||||
|
||||
<div className="qm-grants-form">
|
||||
<select value={categoryId} aria-label="Category to grant" onChange={e => setCategoryId(e.target.value)}>
|
||||
<option value="">Category…</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select value={userId} aria-label="User to grant" onChange={e => setUserId(e.target.value)}>
|
||||
<option value="">User…</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name} — {u.email}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={add}>Grant access</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="qm-error" role="alert">{error}</p>}
|
||||
|
||||
{grants === null ? (
|
||||
<p className="qm-grants-note">Loading…</p>
|
||||
) : grants.length === 0 ? (
|
||||
<p className="qm-grants-note">No grants yet — only moderators can edit questions.</p>
|
||||
) : (
|
||||
<ul className="qm-grants-list">
|
||||
{grants.map(grant => (
|
||||
<li key={grant.id}>
|
||||
<span className="qm-grant-user">{grant.user_name || `User ${grant.user_id}`}</span>
|
||||
<span className="qm-pill">{grant.category_name || `Category ${grant.category_id}`}</span>
|
||||
<span className="qm-grant-email">{grant.user_email}</span>
|
||||
{revoking === grant.id ? (
|
||||
<>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => revoke(grant)}>Confirm</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setRevoking(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Revoke ${grant.user_name || grant.user_id} on ${grant.category_name}`}
|
||||
onClick={() => setRevoking(grant.id)}>Revoke</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/GrantsPanel.test.jsx
Normal file
82
frontend/src/components/GrantsPanel.test.jsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, it, vi, beforeEach } from 'vitest'
|
||||
import GrantsPanel from './GrantsPanel'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
const CATEGORIES = [{ id: 1, name: 'Cardiology' }, { id: 10, name: 'Neurology' }]
|
||||
const GRANT = {
|
||||
id: 5, category_id: 1, category_name: 'Cardiology',
|
||||
user_id: 2, user_name: 'Educator', user_email: 'edu@example.test', created_at: null,
|
||||
}
|
||||
|
||||
const mockApi = (grants = [GRANT]) => {
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/question-categories/grants') return Promise.resolve({ data: grants })
|
||||
if (url === '/question-categories/grantable-users') {
|
||||
return Promise.resolve({ data: [{ id: 2, name: 'Educator', email: 'edu@example.test' }] })
|
||||
}
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads grants only once opened', async () => {
|
||||
mockApi()
|
||||
render(<GrantsPanel categories={CATEGORIES} />)
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
|
||||
expect(await screen.findByText('Educator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cardiology', { selector: '.qm-pill' })).toBeInTheDocument()
|
||||
expect(api.get).toHaveBeenCalledWith('/question-categories/grants')
|
||||
})
|
||||
|
||||
it('grants a category to a user', async () => {
|
||||
mockApi([])
|
||||
api.post.mockResolvedValue({ data: GRANT })
|
||||
render(<GrantsPanel categories={CATEGORIES} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
|
||||
await screen.findByText(/No grants yet/)
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Category to grant'), '1')
|
||||
await userEvent.selectOptions(screen.getByLabelText('User to grant'), '2')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/question-categories/1/grants', { user_id: 2 }))
|
||||
expect(await screen.findByText('Educator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses an incomplete grant and surfaces server errors', async () => {
|
||||
mockApi([])
|
||||
render(<GrantsPanel categories={CATEGORIES} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
|
||||
await screen.findByText(/No grants yet/)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Pick a category and a user')
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Category to grant'), '1')
|
||||
await userEvent.selectOptions(screen.getByLabelText('User to grant'), '2')
|
||||
api.post.mockRejectedValueOnce({ response: { data: { detail: 'Moderators already manage every category' } } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Moderators already manage every category')
|
||||
})
|
||||
|
||||
it('revokes a grant only after confirmation', async () => {
|
||||
mockApi()
|
||||
api.delete.mockResolvedValue({})
|
||||
render(<GrantsPanel categories={CATEGORIES} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
|
||||
await screen.findByText('Educator')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Revoke Educator on Cardiology' }))
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/1/grants/2'))
|
||||
await waitFor(() => expect(screen.queryByText('Educator')).not.toBeInTheDocument())
|
||||
})
|
||||
|
|
@ -63,10 +63,20 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
const [jobs, setJobs] = useState([])
|
||||
const location = useLocation()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
// Educators granted a category also manage questions, without a moderator role.
|
||||
const [canManageQuestions, setCanManageQuestions] = useState(false)
|
||||
|
||||
// Close menu on route change
|
||||
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { setCanManageQuestions(false); return }
|
||||
if (isModerator) { setCanManageQuestions(true); return }
|
||||
api.get('/question-categories/my-grants')
|
||||
.then(res => setCanManageQuestions(!!res.data?.can_manage_questions))
|
||||
.catch(() => setCanManageQuestions(false))
|
||||
}, [user, isModerator])
|
||||
|
||||
// Single job-polling instance for the whole navbar
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
|
@ -91,8 +101,9 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{ to: '/home', label: 'Home' },
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/quizzes', label: 'Quizzes' },
|
||||
{ to: '/analysis', label: 'Analysis' },
|
||||
{ to: '/question-bank', label: 'Question Bank' },
|
||||
...(isModerator ? [{ to: '/questions/manage', label: 'Manage Qs' }] : []),
|
||||
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' }] : []),
|
||||
{ to: '/articles', label: 'Reading' },
|
||||
{ to: '/flashcards', label: 'Cards' },
|
||||
{ to: '/courses', label: 'Courses' },
|
||||
|
|
|
|||
111
frontend/src/pages/AnalysisPage.css
Normal file
111
frontend/src/pages/AnalysisPage.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* Performance analysis — readiness summary and ranked focus areas.
|
||||
Mobile-first: the focus table collapses to stacked cards under 760px. */
|
||||
|
||||
.an-page { max-width: 1080px; margin: 0 auto; }
|
||||
|
||||
.an-header { margin-bottom: 12px; }
|
||||
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
|
||||
.an-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
|
||||
|
||||
.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
|
||||
.an-tabs::-webkit-scrollbar { display: none; }
|
||||
.an-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
|
||||
color: var(--text-muted); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
|
||||
/* ── Summary tiles ────────────────────────────────────────────────── */
|
||||
.an-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||
.an-stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; }
|
||||
.an-stat-value { display: block; font-size: 1.5rem; font-weight: 700; line-height: 1.15; }
|
||||
.an-stat-label { display: block; font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; }
|
||||
.an-stat-sub { display: block; font-size: 0.74rem; color: var(--text-subtle); margin-top: 3px; }
|
||||
|
||||
.an-locked {
|
||||
background: var(--card-bg); border: 1px dashed var(--border); border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 14px; color: var(--text-muted); font-size: 0.87rem;
|
||||
}
|
||||
.an-locked strong { display: block; color: var(--text); margin-bottom: 4px; font-size: 0.95rem; }
|
||||
|
||||
/* ── Next step ────────────────────────────────────────────────────── */
|
||||
.an-nextstep {
|
||||
background: var(--card-bg); border: 1px solid var(--primary); border-radius: 10px;
|
||||
padding: 15px 17px; margin-bottom: 16px;
|
||||
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.an-nextstep-text { flex: 1; min-width: 200px; }
|
||||
.an-nextstep h2 { margin: 0 0 3px; font-size: 1rem; }
|
||||
.an-nextstep p { margin: 0; color: var(--text-muted); font-size: 0.84rem; }
|
||||
.an-nextstep-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.an-nextstep select {
|
||||
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Focus areas ──────────────────────────────────────────────────── */
|
||||
.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.an-group-toggle button {
|
||||
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
|
||||
border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.an-group-toggle button[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
||||
|
||||
.an-focus-head {
|
||||
display: grid; grid-template-columns: 28px 1fr 90px 90px 110px;
|
||||
gap: 12px; padding: 0 14px 7px; align-items: center;
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--text-subtle);
|
||||
}
|
||||
.an-focus-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.an-focus {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.an-focus.is-focus { border-left: 3px solid #d97706; }
|
||||
.an-focus-row {
|
||||
display: grid; grid-template-columns: 28px 1fr 90px 90px 110px;
|
||||
gap: 12px; padding: 12px 14px; align-items: center;
|
||||
width: 100%; background: none; border: none; text-align: left; font: inherit; cursor: pointer; color: var(--text);
|
||||
}
|
||||
.an-focus-row:hover { background: var(--bg); }
|
||||
.an-focus-toggle { color: var(--text-muted); font-size: 0.8rem; }
|
||||
.an-focus-name { min-width: 0; }
|
||||
.an-focus-name strong { display: block; font-size: 0.9rem; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.an-focus-parent { font-size: 0.73rem; color: var(--text-subtle); }
|
||||
.an-focus-tag {
|
||||
display: inline-block; margin-top: 4px; font-size: 0.64rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em; border-radius: 20px; padding: 2px 8px;
|
||||
background: #fff7ed; color: #b45309; border: 1px solid #fed7aa;
|
||||
}
|
||||
.an-metric { font-size: 0.9rem; font-weight: 650; }
|
||||
.an-metric small { display: block; font-size: 0.68rem; font-weight: 500; color: var(--text-subtle); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.an-status { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.an-status.is-focus { color: #b45309; }
|
||||
.an-status.is-proficient { color: var(--correct-fg); }
|
||||
.an-status.is-no_data { color: var(--text-subtle); }
|
||||
|
||||
.an-focus-detail { padding: 0 14px 14px; border-top: 1px solid var(--border); }
|
||||
.an-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 12px 0; }
|
||||
.an-detail-block-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-subtle); margin-bottom: 5px; }
|
||||
.an-detail-value { font-size: 0.92rem; font-weight: 650; margin-bottom: 6px; }
|
||||
.an-bar { height: 7px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
||||
.an-bar > span { display: block; height: 100%; background: var(--primary); }
|
||||
.an-bar > span.is-correct { background: var(--correct-fg); }
|
||||
.an-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.an-basis { color: var(--text-muted); font-size: 0.76rem; line-height: 1.6; margin: 16px 0 0; }
|
||||
.an-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 30px 18px; text-align: center; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.an-focus-head { display: none; }
|
||||
.an-focus-row { grid-template-columns: 24px 1fr auto; grid-template-areas: 'tog name metric' 'tog meta metric'; }
|
||||
.an-focus-row .an-focus-toggle { grid-area: tog; align-self: start; padding-top: 2px; }
|
||||
.an-focus-row .an-focus-name { grid-area: name; }
|
||||
.an-focus-row .an-metric-readiness { grid-area: metric; text-align: right; }
|
||||
.an-focus-row .an-metric-relevance { display: none; }
|
||||
.an-focus-row .an-status { grid-area: meta; }
|
||||
.an-nextstep-controls { width: 100%; }
|
||||
.an-nextstep-controls .btn { flex: 1; }
|
||||
.an-detail-actions .btn { flex: 1; text-align: center; }
|
||||
}
|
||||
188
frontend/src/pages/AnalysisPage.jsx
Normal file
188
frontend/src/pages/AnalysisPage.jsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import CategoryPerformance from '../components/CategoryPerformance'
|
||||
import './AnalysisPage.css'
|
||||
|
||||
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
|
||||
|
||||
function FocusRow({ row, onStartCategory }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const coverageLabel = `${row.seen_questions}/${row.available}`
|
||||
const accuracyLabel = row.answered ? `${row.accuracy}% (${row.correct} of ${row.answered})` : 'Not attempted'
|
||||
|
||||
return (
|
||||
<div className={`an-focus${row.is_focus_area ? ' is-focus' : ''}`}>
|
||||
<button type="button" className="an-focus-row" aria-expanded={open} onClick={() => setOpen(v => !v)}>
|
||||
<span className="an-focus-toggle" aria-hidden="true">{open ? '▾' : '▸'}</span>
|
||||
<span className="an-focus-name">
|
||||
<strong>{row.name}</strong>
|
||||
{row.parent_name && <span className="an-focus-parent">{row.parent_name}</span>}
|
||||
{row.is_focus_area && <span className="an-focus-tag">Focus area</span>}
|
||||
</span>
|
||||
<span className="an-metric an-metric-readiness">
|
||||
{row.readiness !== null ? `${row.readiness}%` : row.accuracy !== null ? `${row.accuracy}%` : '—'}
|
||||
<small>{row.readiness !== null ? 'Readiness' : 'Accuracy'}</small>
|
||||
</span>
|
||||
<span className="an-metric an-metric-relevance">
|
||||
{row.relevance}%
|
||||
<small>Relevance</small>
|
||||
</span>
|
||||
<span className={`an-status is-${row.status}`}>{STATUS_LABEL[row.status]}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="an-focus-detail">
|
||||
<div className="an-detail-grid">
|
||||
<div>
|
||||
<div className="an-detail-block-label">Questions seen</div>
|
||||
<div className="an-detail-value">{coverageLabel}</div>
|
||||
<div className="an-bar"><span style={{ width: `${row.coverage}%` }} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="an-detail-block-label">Answered correctly</div>
|
||||
<div className="an-detail-value">{accuracyLabel}</div>
|
||||
<div className="an-bar"><span className="is-correct" style={{ width: `${row.accuracy || 0}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="an-detail-actions">
|
||||
{row.article_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/articles/${row.article_id}`}>
|
||||
Read {row.article_title}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onStartCategory(row.category_id)}>
|
||||
Practise this topic
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnalysisPage() {
|
||||
const [tab, setTab] = useState('recommendations')
|
||||
const [group, setGroup] = useState('systems')
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [count, setCount] = useState(10)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
api.get('/study-tools/recommendations', { params: { group } })
|
||||
.then(res => setData(res.data))
|
||||
.catch(() => setError('Could not load your analysis'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [group])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const startAdaptive = () => navigate(`/quizzes/create?adaptive=1&count=${count}`)
|
||||
const startCategory = (categoryId) => navigate(`/quizzes/create?category=${categoryId}&count=${count}`)
|
||||
|
||||
return (
|
||||
<div className="an-page">
|
||||
<div className="an-header">
|
||||
<h1>Your performance analysis</h1>
|
||||
<p>Built from your own answers — where you stand, and what to study next.</p>
|
||||
</div>
|
||||
|
||||
<div className="an-tabs" role="tablist">
|
||||
<button className="an-tab" role="tab" aria-selected={tab === 'performance'} onClick={() => setTab('performance')}>
|
||||
Performance
|
||||
</button>
|
||||
<button className="an-tab" role="tab" aria-selected={tab === 'recommendations'} onClick={() => setTab('recommendations')}>
|
||||
Recommendations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="an-empty" role="alert">{error}</p>}
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="loading"><div className="spinner" /> Loading…</div>
|
||||
) : !data ? null : (
|
||||
<>
|
||||
<div className="an-summary">
|
||||
<div className="an-stat">
|
||||
<span className="an-stat-value">{data.unlocked && data.overall_accuracy !== null ? `${data.overall_accuracy}%` : '—'}</span>
|
||||
<span className="an-stat-label">Overall accuracy</span>
|
||||
{!data.unlocked && <span className="an-stat-sub">Unlocks after {data.answers_needed} more answers</span>}
|
||||
</div>
|
||||
<div className="an-stat">
|
||||
<span className="an-stat-value">{data.unique_questions_seen}<span style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>/{data.bank_total}</span></span>
|
||||
<span className="an-stat-label">Questions seen</span>
|
||||
</div>
|
||||
<div className="an-stat">
|
||||
<span className="an-stat-value">{data.total_answered}</span>
|
||||
<span className="an-stat-label">Answers recorded</span>
|
||||
</div>
|
||||
<div className="an-stat">
|
||||
<span className="an-stat-value">{data.focus_areas.filter(r => r.is_focus_area).length}</span>
|
||||
<span className="an-stat-label">Focus areas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!data.unlocked && (
|
||||
<div className="an-locked">
|
||||
<strong>Readiness is still locked</strong>
|
||||
Answer {data.answers_needed} more question{data.answers_needed !== 1 ? 's' : ''} and each topic gets a
|
||||
readiness estimate. Until then the table ranks topics by raw accuracy and how much of the bank they cover.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="an-nextstep">
|
||||
<div className="an-nextstep-text">
|
||||
<h2>Next step: adaptive session</h2>
|
||||
<p>Practises what you have not seen first, then the topics you get wrong most.</p>
|
||||
</div>
|
||||
<div className="an-nextstep-controls">
|
||||
<label>
|
||||
<span className="sr-only">Questions</span>
|
||||
<select value={count} aria-label="Questions" onChange={e => setCount(Number(e.target.value))}>
|
||||
{[5, 10, 15, 20, 30].map(n => <option key={n} value={n}>{n} questions</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn btn-primary" onClick={startAdaptive}>Start adaptive session</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'recommendations' ? (
|
||||
<>
|
||||
<div className="an-group-toggle">
|
||||
{[['systems', 'Systems'], ['subtopics', 'Subtopics']].map(([value, label]) => (
|
||||
<button key={value} aria-pressed={group === value} onClick={() => setGroup(value)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.focus_areas.length === 0 ? (
|
||||
<div className="an-empty">No categorised questions yet — once questions are filed under a system, your focus areas appear here.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="an-focus-head">
|
||||
<span />
|
||||
<span>Topic</span>
|
||||
<span>Readiness</span>
|
||||
<span>Relevance</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div className="an-focus-list">
|
||||
{data.focus_areas.map(row => (
|
||||
<FocusRow key={row.category_id} row={row} onStartCategory={startCategory} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="an-basis">{data.basis}</p>
|
||||
</>
|
||||
) : (
|
||||
<CategoryPerformance />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
frontend/src/pages/AnalysisPage.test.jsx
Normal file
73
frontend/src/pages/AnalysisPage.test.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { expect, it, vi, beforeEach } from 'vitest'
|
||||
import AnalysisPage from './AnalysisPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
|
||||
vi.mock('../components/CategoryPerformance', () => ({ default: () => <div data-testid="category-performance" /> }))
|
||||
|
||||
const area = (over = {}) => ({
|
||||
category_id: 1, name: 'Cardiology', parent_id: null, parent_name: null,
|
||||
answered: 10, correct: 5, seen_questions: 8, available: 40,
|
||||
coverage: 20, accuracy: 50, readiness: 55, relevance: 12,
|
||||
status: 'focus', is_focus_area: true, article_id: 7, article_title: 'Kawasaki disease', ...over,
|
||||
})
|
||||
|
||||
const payload = (over = {}) => ({
|
||||
group: 'systems', unlocked: true, answers_needed: 0, total_answered: 60,
|
||||
unique_questions_seen: 42, bank_total: 330, overall_accuracy: 64.0,
|
||||
focus_areas: [area()], basis: 'Readiness shrinks each category toward your overall accuracy.', ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: payload() })
|
||||
})
|
||||
|
||||
it('summarises readiness and ranks focus areas', async () => {
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
expect(await screen.findByText('64%')).toBeInTheDocument()
|
||||
expect(screen.getByText('42')).toBeInTheDocument() // questions seen
|
||||
expect(screen.getByText('60')).toBeInTheDocument() // answers recorded
|
||||
|
||||
const row = screen.getByText('Cardiology').closest('.an-focus')
|
||||
expect(within(row).getByText('55%')).toBeInTheDocument() // readiness
|
||||
expect(within(row).getByText('12%')).toBeInTheDocument() // relevance
|
||||
expect(within(row).getByText('Focus area', { selector: '.an-status' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands a focus area to show coverage and the next step', async () => {
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await screen.findByText('Cardiology')
|
||||
await userEvent.click(screen.getByRole('button', { expanded: false, name: /Cardiology/ }))
|
||||
|
||||
expect(screen.getByText('8/40')).toBeInTheDocument()
|
||||
expect(screen.getByText('50% (5 of 10)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Read Kawasaki disease/ })).toHaveAttribute('href', '/articles/7')
|
||||
expect(screen.getByRole('button', { name: 'Practise this topic' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains that readiness is locked until enough answers exist', async () => {
|
||||
api.get.mockResolvedValue({ data: payload({ unlocked: false, answers_needed: 12, overall_accuracy: null }) })
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
expect(await screen.findByText('Readiness is still locked')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Answer 12 more questions/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reloads focus areas when the grouping changes', async () => {
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await screen.findByText('Cardiology')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Subtopics' }))
|
||||
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith('/study-tools/recommendations',
|
||||
{ params: { group: 'subtopics' } }))
|
||||
})
|
||||
|
||||
it('switches to the performance tab', async () => {
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await screen.findByText('Cardiology')
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Performance' }))
|
||||
expect(screen.getByTestId('category-performance')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cardiology')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -126,3 +126,35 @@
|
|||
.qm-bulk { left: 0; right: 0; bottom: 0; width: 100%; transform: none; border-radius: 14px 14px 0 0; padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.qm-bulk-spacer { display: none; }
|
||||
}
|
||||
|
||||
/* ── Grant scope banner ───────────────────────────────────────────── */
|
||||
.qm-scope { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.qm-scope-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-subtle); }
|
||||
|
||||
/* ── Educator grants panel ────────────────────────────────────────── */
|
||||
.qm-grants { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; margin-top: 18px; }
|
||||
.qm-grants-toggle {
|
||||
display: flex; justify-content: space-between; align-items: center; width: 100%;
|
||||
background: none; border: none; padding: 13px 15px; font: inherit; font-weight: 650;
|
||||
font-size: 0.92rem; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.qm-grants-body { padding: 0 15px 15px; border-top: 1px solid var(--border); }
|
||||
.qm-grants-note { color: var(--text-muted); font-size: 0.82rem; line-height: 1.6; margin: 10px 0; }
|
||||
.qm-grants-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
|
||||
.qm-grants-form select {
|
||||
flex: 1; min-width: 160px; padding: 7px 10px; border: 1px solid var(--border);
|
||||
border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: 0.85rem;
|
||||
}
|
||||
.qm-grants-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.qm-grants-list li {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 9px 11px; background: var(--bg); border-radius: 8px; font-size: 0.85rem;
|
||||
}
|
||||
.qm-grant-user { font-weight: 650; }
|
||||
.qm-grant-email { color: var(--text-muted); font-size: 0.78rem; flex: 1; min-width: 120px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.qm-grants-form select { min-width: 100%; }
|
||||
.qm-grants-form .btn { width: 100%; }
|
||||
.qm-grants-list li .btn { flex: 1; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors'
|
||||
import GrantsPanel from '../components/GrantsPanel'
|
||||
import './QuestionManagerPage.css'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
|
@ -26,6 +28,9 @@ const TILES = [
|
|||
]
|
||||
|
||||
export default function QuestionManagerPage() {
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [scope, setScope] = useState(null)
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [summary, setSummary] = useState(null)
|
||||
|
|
@ -76,6 +81,7 @@ export default function QuestionManagerPage() {
|
|||
useEffect(() => { loadSummary() }, [loadSummary])
|
||||
useEffect(() => {
|
||||
api.get('/question-categories').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
|
||||
api.get('/question-categories/my-grants').then(res => setScope(res.data)).catch(() => setScope(null))
|
||||
}, [])
|
||||
|
||||
const refresh = () => { load(); loadSummary(); setSelected(new Set()) }
|
||||
|
|
@ -136,7 +142,11 @@ export default function QuestionManagerPage() {
|
|||
<div className="qm-header">
|
||||
<div>
|
||||
<h1>Question manager</h1>
|
||||
<p>Review, categorise and fix gaps across the whole question bank.</p>
|
||||
<p>
|
||||
{scope && !scope.is_moderator
|
||||
? `Your editorial grants cover ${scope.categories.length} categor${scope.categories.length === 1 ? 'y' : 'ies'}.`
|
||||
: 'Review, categorise and fix gaps across the whole question bank.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="qm-header-actions">
|
||||
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
|
||||
|
|
@ -144,6 +154,13 @@ export default function QuestionManagerPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{scope && !scope.is_moderator && scope.categories.length > 0 && (
|
||||
<div className="qm-scope" role="note">
|
||||
<span className="qm-scope-label">Your categories</span>
|
||||
{scope.categories.map(c => <span key={c.id} className="qm-pill">{c.name}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="qm-health">
|
||||
{TILES.map(tile => (
|
||||
<button key={tile.label} className={`qm-tile${tile.warn ? ' is-warn' : ''}`}
|
||||
|
|
@ -234,6 +251,8 @@ export default function QuestionManagerPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isModerator && <GrantsPanel categories={categories} />}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="qm-bulk" role="region" aria-label="Bulk actions">
|
||||
<span className="qm-bulk-count">{selected.size} selected</span>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import QuestionManagerPage from './QuestionManagerPage'
|
|||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, role: 'moderator' } }) }))
|
||||
vi.mock('../components/GrantsPanel', () => ({ default: () => <div data-testid="grants-panel" /> }))
|
||||
|
||||
const SUMMARY = { total: 40, uncategorized: 7, no_explanation: 3, no_difficulty: 12, private: 2, mine: 5 }
|
||||
const QUESTIONS = [
|
||||
|
|
@ -17,6 +19,9 @@ const mockApi = (questions = QUESTIONS, total = questions.length) => {
|
|||
api.get.mockImplementation(url => {
|
||||
if (url === '/questions/manage/summary') return Promise.resolve({ data: SUMMARY })
|
||||
if (url === '/questions/bank') return Promise.resolve({ data: { total, questions } })
|
||||
if (url === '/question-categories/my-grants') {
|
||||
return Promise.resolve({ data: { is_moderator: true, can_manage_questions: true, categories: [] } })
|
||||
}
|
||||
return Promise.resolve({ data: [{ id: 9, name: 'Cardiology' }] })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1107,9 +1107,24 @@ const timerStarted = timeLeft !== null
|
|||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||
}}>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{current.category_breadcrumbs?.length ? current.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && <span aria-hidden="true"> › </span>}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
<div className="quiz-stem" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 id="quiz-question-heading" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<div className="quiz-qmeta">
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">
|
||||
{current.category_breadcrumbs?.length
|
||||
? current.category_breadcrumbs.map((category, index) => (
|
||||
<span key={category.id}>
|
||||
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true">›</span>}
|
||||
<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
</span>
|
||||
))
|
||||
: <span>Uncategorized</span>}
|
||||
</nav>
|
||||
{current.difficulty && <span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>}
|
||||
<span className="quiz-meta-pill">
|
||||
{current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quiz-stem">
|
||||
<h3 id="quiz-question-heading" style={{ marginBottom: 0 }}>
|
||||
<ManualHighlightText
|
||||
text={questionStem(current)}
|
||||
textId={`${current.id}::question`}
|
||||
|
|
@ -1118,25 +1133,13 @@ const timerStarted = timeLeft !== null
|
|||
onRemoveHighlight={removeJoinedHighlight}
|
||||
/>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.4rem',
|
||||
padding: 4,
|
||||
lineHeight: 1,
|
||||
transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.15)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||||
>
|
||||
{favorites.includes(current.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
||||
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
{favorites.includes(current.id) ? '★' : '☆'} <span>Mark</span>
|
||||
</button>
|
||||
{voices.length > 0 && (
|
||||
<TTSButton
|
||||
key={`${current.id}_${selectedVoice || 'default'}`}
|
||||
|
|
@ -1160,23 +1163,19 @@ const timerStarted = timeLeft !== null
|
|||
/>
|
||||
)}
|
||||
{voices.length > 0 && (
|
||||
<button
|
||||
className={`btn btn-sm ${readThrough ? 'btn-primary' : 'btn-secondary'}`}
|
||||
<button type="button" className={readThrough ? 'is-on' : ''}
|
||||
onClick={() => setReadThrough(v => !v)}
|
||||
title="Read each question aloud and advance automatically"
|
||||
>
|
||||
{readThrough ? 'Stop listen-through' : 'Listen through'}
|
||||
title="Read each question aloud and advance automatically">
|
||||
▶ <span>{readThrough ? 'Stop' : 'Listen through'}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||||
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||||
Clear
|
||||
<button type="button" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights}
|
||||
disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||||
✎ <span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
|
||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||
</span>
|
||||
{current.image_path && (
|
||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
||||
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ vi.mock('../components/MyNote', () => ({ default: () => null }))
|
|||
vi.mock('../components/TeachChat', () => ({ default: ({ attemptId }) => <div data-testid="tutor-context">Study tutor {attemptId}</div> }))
|
||||
|
||||
const questions = [
|
||||
{ id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] },
|
||||
{ id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', difficulty: 'hard', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] },
|
||||
{ id: 2, question_text: 'Full second clinical question.', question_type: 'mcq', options: ['Third answer', 'Fourth answer'], category_breadcrumbs: [] },
|
||||
]
|
||||
let mode
|
||||
|
|
@ -57,6 +57,22 @@ async function begin(study = true) {
|
|||
}
|
||||
|
||||
describe('quiz player', () => {
|
||||
it('keeps question metadata on one compact strip with a single action bar', async () => {
|
||||
await begin()
|
||||
// The category trail used to be a tall wrapping block above the stem.
|
||||
const meta = document.querySelector('.quiz-qmeta')
|
||||
expect(meta).toBeInTheDocument()
|
||||
expect(within(meta).getByRole('link', { name: 'Pediatrics' })).toBeInTheDocument()
|
||||
expect(within(meta).getByRole('link', { name: 'Neonatology' })).toBeInTheDocument()
|
||||
expect(within(meta).getByText('hard')).toBeInTheDocument()
|
||||
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
|
||||
|
||||
// Mark moved off the stem into the action bar, so the stem is text only.
|
||||
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
|
||||
expect(within(bar).getByTitle('Add to favorites')).toBeInTheDocument()
|
||||
expect(document.querySelector('.quiz-stem button')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries failed resume without offering Start or clearing saved answers', async () => {
|
||||
mode = 'study'
|
||||
const originalGet = api.get.getMockImplementation()
|
||||
|
|
|
|||
|
|
@ -20,10 +20,42 @@
|
|||
.quiz-top-actions .quiz-review-button { background: #496fa5; color: white; font-weight: 650; text-transform: uppercase; padding: 12px 18px; margin-left: 12px; }
|
||||
.quiz-player button:focus-visible, .quiz-player a:focus-visible, .quiz-results a:focus-visible { outline: 3px solid #779ad1; outline-offset: 3px; }
|
||||
.quiz-player .question-card { border: 0; padding: 0; border-radius: 0; box-shadow: none; margin-bottom: 22px; }
|
||||
.quiz-breadcrumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 78px; padding: 20px 0; font-size: .95rem; color: #737982; }
|
||||
/* One compact meta strip — the category trail used to be a 78px block that
|
||||
wrapped into several rows and pushed the stem off the screen. */
|
||||
.quiz-qmeta { display: flex; align-items: center; gap: 8px; padding: 14px 0 10px; min-width: 0; }
|
||||
.quiz-breadcrumbs {
|
||||
display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;
|
||||
font-size: .82rem; color: #737982;
|
||||
white-space: nowrap; overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.quiz-breadcrumbs::-webkit-scrollbar { display: none; }
|
||||
.quiz-breadcrumbs > span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.quiz-breadcrumb-sep { color: #b6bcc6; }
|
||||
.quiz-breadcrumbs a { color: #416fa7; text-decoration: none; }
|
||||
.quiz-breadcrumbs a:hover { text-decoration: underline; }
|
||||
.quiz-player .quiz-stem { padding: 12px 0 24px; }
|
||||
.quiz-meta-pill {
|
||||
flex-shrink: 0; font-size: .68rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
|
||||
border: 1px solid #dfe3ea; border-radius: 999px; padding: 3px 10px; color: #6b727d; background: #f8f9fb;
|
||||
}
|
||||
.quiz-meta-pill.is-easy { background: #eef7f3; border-color: #bfe0d2; color: #327b64; }
|
||||
.quiz-meta-pill.is-medium { background: #fdf6e8; border-color: #ecd9ae; color: #8a6417; }
|
||||
.quiz-meta-pill.is-hard { background: #fbecf0; border-color: #e9bfca; color: #a13c51; }
|
||||
|
||||
/* Question action bar — one row of icon buttons instead of stacked strips. */
|
||||
.quiz-actionbar {
|
||||
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
||||
padding: 10px 0; margin: 0 0 4px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.quiz-actionbar button, .quiz-actionbar .manual-highlight-toolbar button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: transparent; border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 7px 10px; font: inherit; font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .03em; text-transform: uppercase; color: #6b727d; cursor: pointer;
|
||||
}
|
||||
.quiz-actionbar button:hover:not(:disabled) { background: #f4f6fb; color: #333; }
|
||||
.quiz-actionbar button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.quiz-actionbar button.is-on { background: #e5ecf8; color: #365b8d; }
|
||||
.quiz-player .quiz-stem { padding: 4px 0 14px; }
|
||||
.quiz-player .question-card h3 { font-size: 1.08rem; line-height: 1.65; font-weight: 400; font-family: inherit; }
|
||||
.quiz-player .question-card .options { gap: 0; margin-top: 24px !important; padding: 18px 0 8px; border-top: 1px solid var(--border); }
|
||||
.quiz-player .question-card .option { padding: 15px 12px; border: 0; border-radius: 0; gap: 18px; font: inherit; font-size: 1rem; text-align: left; background: white; }
|
||||
|
|
@ -64,7 +96,10 @@
|
|||
.quiz-top-actions button[aria-label="Next question"] { font-size: 0; }
|
||||
.quiz-top-actions button[aria-label="Next question"]::after { content: '›'; font-size: 1.25rem; }
|
||||
.quiz-top-actions .quiz-review-button { padding: 9px; margin-left: 0; font-size: .73rem; }
|
||||
.quiz-breadcrumbs { min-height: 60px; font-size: .86rem; }
|
||||
.quiz-qmeta { padding: 10px 0 8px; }
|
||||
.quiz-breadcrumbs { font-size: .76rem; }
|
||||
.quiz-actionbar button span, .quiz-actionbar .manual-highlight-toolbar button span { display: none; }
|
||||
.quiz-actionbar button { padding: 8px 9px; font-size: .9rem; }
|
||||
.quiz-player .question-card .option { gap: 10px; padding: 13px 8px; }
|
||||
.quiz-player .option-status { margin-left: 45px; }
|
||||
.quiz-response-stat { flex-wrap: wrap; gap: 8px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue