From 8137d5b20ce9febc79ec4c7658bc6a89e3a51b6d Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 6 Apr 2026 21:31:05 +0200 Subject: [PATCH] Decouple course quizzes, replace passlib, add security hardening - Fully decouple course quizzes from main quiz system (hidden from dashboard stats, history, search, results page) - Course quiz results show "Back to Course" instead of retake/delete - Add allow_review toggle for course creators to control answer review - Show quiz title on course page, hide pool size from students - Add course thumbnails to browse cards - Replace passlib with bcrypt directly (compatible with existing hashes) - Add HIBP breached password warnings on register/reset/change password - Add CLI management tools (reset-password, set-role, stats, etc.) - Fix quiz PATCH endpoint: ownership check instead of moderator-only - Add max_length validation on course/module/lesson titles - Fix score display bug on results page (0 of N when review disabled) - Fix question count on course quiz start (show per-attempt, not pool) - Improve suspend warning for timed course quizzes with max attempts - Clean up validation error messages (show "Invalid email" not Pydantic dump) - Add DDL migration for allow_review column Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + CLAUDE.md | 21 +- backend/app/cli.py | 264 +++++++++++ backend/app/main.py | 30 ++ backend/app/models/attempt.py | 3 +- backend/app/models/question.py | 2 + backend/app/models/quiz.py | 4 + backend/app/routers/attempts.py | 158 +++++-- backend/app/routers/auth.py | 67 ++- backend/app/routers/courses.py | 491 ++++++++++++++++++-- backend/app/routers/questions.py | 312 ++++++++++++- backend/app/routers/quizzes.py | 35 +- backend/app/schemas/attempt.py | 2 + backend/app/schemas/quiz.py | 1 + backend/app/utils/auth.py | 7 +- backend/requirements.txt | 4 +- backup-db.sh | 25 ++ docker-compose.yml | 19 + frontend/package.json | 14 +- frontend/src/components/Navbar.jsx | 1 - frontend/src/components/RichEditor.jsx | 149 +++++-- frontend/src/index.css | 17 + frontend/src/pages/AccountPage.jsx | 7 +- frontend/src/pages/CourseDetailPage.jsx | 134 ++++-- frontend/src/pages/CourseEditorPage.jsx | 521 ++++++++++++++++++---- frontend/src/pages/CoursesPage.jsx | 38 +- frontend/src/pages/DashboardPage.jsx | 30 +- frontend/src/pages/ForgotPasswordPage.jsx | 5 +- frontend/src/pages/LandingPage.jsx | 18 +- frontend/src/pages/LoginPage.jsx | 5 +- frontend/src/pages/QuestionBankPage.jsx | 319 +++++++++++-- frontend/src/pages/QuizPage.jsx | 67 ++- frontend/src/pages/RegisterPage.jsx | 16 +- frontend/src/pages/ResetPasswordPage.jsx | 7 +- frontend/src/pages/ResultsPage.jsx | 248 +++++----- frontend/src/pages/SettingsPage.jsx | 38 ++ frontend/src/pages/UploadPage.jsx | 1 - 37 files changed, 2574 insertions(+), 509 deletions(-) create mode 100644 backend/app/cli.py create mode 100755 backup-db.sh diff --git a/.gitignore b/.gitignore index 0197dcd..c6d2d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist/ .DS_Store *.swp backend/.env.save + +# Database backups +backups/ diff --git a/CLAUDE.md b/CLAUDE.md index 7425f81..4cd9394 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ PedsHub is a pediatric medical learning platform with an integrated LMS. Admins ## Stack - **Backend**: FastAPI + SQLAlchemy + PostgreSQL 16 (pgvector) + Redis + Celery -- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Nginx +- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Milkdown (markdown WYSIWYG) + Nginx - **AI**: LiteLLM proxy routes to Claude/GPT/Gemini/Bedrock. `_proxy_model()` in ai_service.py adds `openai/` prefix for the proxy. - **Vectors**: ChromaDB for document page chunks (RAG), pgvector for question embeddings (semantic search) - **Config**: Backend reads `.env` via pydantic-settings. Frontend uses runtime `window.__APP_CONFIG__` injected by `docker-entrypoint.sh` (not Vite build-time env). @@ -26,6 +26,7 @@ Browser → Nginx (frontend) → FastAPI (4 uvicorn workers) ├── ChromaDB (document page chunks for extraction context) ├── Redis (Celery broker, rate limits, settings, job progress, session locks) └── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration) + └── db-backup (daily PostgreSQL dumps, 14 daily / 4 weekly / 6 monthly retention, ./backups/) ## Course/LMS system Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons. @@ -36,8 +37,11 @@ Any user can create courses (not just moderators). Course structure: Course → - **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate` - **Status**: draft (creator-only) → published (visible to all) → archived - **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder) -- **Question bank integration**: quiz lessons select questions from bank by category/tags, creates a quiz via `POST /questions/from-bank` -- **Any user can create quizzes** from the question bank (not just moderators). User quizzes are private by default (`is_published=0`, `is_shared=0`) +- **Course quizzes**: fully decoupled from main quiz system. `POST /courses/{id}/quiz` copies questions and creates an independent quiz with `course_id` set. Hidden from main quizzes page, search, dashboard stats, and attempt history. Creator sets mode (timed/study), time limit, max attempts, questions per attempt (random pool), and `allow_review` (whether students can review answers). Results page is course-aware — shows "Back to Course" instead of retake/all quizzes/delete. Users see attempt history + review links on the course page only. +- **User roles**: `admin`, `moderator`, `user`. Only moderators and admins can create courses. Any user can create quizzes from the question bank. +- **Enrollee analytics**: `GET /courses/{id}/enrollees` returns progress + quiz scores. `GET /courses/{id}/enrollees/export` exports CSV. +- **Question ownership**: questions have `user_id` and `is_shared`. Users see shared + own questions. `PATCH /questions/{id}/share` toggles visibility. +- **Rich editor**: Milkdown (ProseMirror-based markdown WYSIWYG) for lesson content. Supports GFM tables, code blocks, LaTeX math (`$formula$`). No JSX parsing issues with `<` or `{`. ``` ## Key directories @@ -74,6 +78,7 @@ frontend/src/ components/ Navbar.jsx — Auth-aware nav with jobs badge TeachChat.jsx — AI tutor drawer (lazy loaded, markdown/GFM tables) + RichEditor.jsx — Milkdown markdown WYSIWYG editor with toolbar, GFM tables, math, history ``` ## Database tables (key ones) @@ -82,8 +87,8 @@ frontend/src/ | users | Accounts with role (admin/moderator/user) | — | | pdf_documents | Uploaded PDFs | user_id → users | | sections | Page ranges within a document | document_id → pdf_documents | -| quizzes | Quiz metadata | section_id → sections (nullable), user_id → users | -| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable) | +| quizzes | Quiz metadata (course_id set = course-only, allow_review controls student access) | section_id → sections (nullable), user_id → users, course_id → courses (nullable) | +| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable), user_id → users (nullable) | | quiz_question_links | Quiz ↔ Question many-to-many | quiz_id, question_id | | flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users | | flashcards | Individual cards (front/back) | deck_id → flashcard_decks | @@ -112,3 +117,9 @@ frontend/src/ - Don't use `[someValue === null]` as a useEffect dependency — it evaluates to a constant boolean - Don't `docker compose restart` expecting code changes to apply — must rebuild - Don't use `window.confirm()` — user hates browser popups, use inline confirmation or the Dialog component +- Don't use MDXEditor — it's an MDX parser that chokes on `<` and `{` in medical content. Milkdown (CommonMark) is used instead. +- Don't create quizzes via `POST /questions/from-bank` for courses — use `POST /courses/{id}/quiz` which copies questions and hides the quiz from the main page. +- Don't show course quiz data on the main quizzes page, dashboard stats, or attempt history — course quizzes are fully decoupled. Filter with `Quiz.course_id.is_(None)`. +- Don't show "from pool of N" to users on course quiz display — just show the number of questions per attempt. +- Don't allow users to delete course quiz attempts — the backend returns 403. +- Don't put documents listing on the dashboard — it's in Settings page under Nextcloud. diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..ba09e56 --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,264 @@ +"""CLI management commands. Run inside the backend container: + + docker compose exec backend python -m app.cli [args] + +Commands: + list-users List all users + reset-password Reset a user's password + set-role Change user role (admin/moderator/user) + verify-email Force-verify a user's email + delete-user Delete a user and their data + stats Show platform statistics + list-courses List all courses with status + list-quizzes List quizzes (--all includes deleted) + cleanup-redis Clear expired/orphaned Redis keys + export-users Export users to CSV +""" +import sys +import csv +import io + +from app.database import SessionLocal +from app.models.user import User +from app.utils.auth import get_password_hash + + +def reset_password(email: str, new_password: str): + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email.lower().strip()).first() + if not user: + print(f"Error: No user found with email '{email}'") + return False + if len(new_password) < 6: + print("Error: Password must be at least 6 characters") + return False + user.hashed_password = get_password_hash(new_password) + db.commit() + print(f"Password reset for {user.name} ({user.email}) — role: {user.role}") + return True + finally: + db.close() + + +def list_users(): + db = SessionLocal() + try: + users = db.query(User).order_by(User.id).all() + print(f"{'ID':<5} {'Name':<25} {'Email':<35} {'Role':<12}") + print("-" * 77) + for u in users: + print(f"{u.id:<5} {(u.name or '-'):<25} {u.email:<35} {u.role:<12}") + print(f"\n{len(users)} user(s)") + finally: + db.close() + + +def set_role(email: str, role: str): + if role not in ("admin", "moderator", "user"): + print(f"Error: Invalid role '{role}'. Must be: admin, moderator, user") + return False + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email.lower().strip()).first() + if not user: + print(f"Error: No user found with email '{email}'") + return False + old_role = user.role + user.role = role + db.commit() + print(f"{user.name} ({user.email}): {old_role} -> {role}") + return True + finally: + db.close() + + +def verify_email(email: str): + from datetime import datetime + from app.models.email_verification import EmailVerification + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email.lower().strip()).first() + if not user: + print(f"Error: No user found with email '{email}'") + return False + v = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() + if not v: + print(f"{user.email} has no verification record (already verified or legacy user)") + return True + if v.verified_at: + print(f"{user.email} is already verified (at {v.verified_at})") + return True + v.verified_at = datetime.utcnow() + db.commit() + print(f"Email verified for {user.name} ({user.email})") + return True + finally: + db.close() + + +def delete_user(email: str): + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email.lower().strip()).first() + if not user: + print(f"Error: No user found with email '{email}'") + return False + confirm = input(f"Delete {user.name} ({user.email}, role={user.role})? Type 'yes' to confirm: ") + if confirm.strip().lower() != "yes": + print("Cancelled.") + return False + db.delete(user) + db.commit() + print(f"Deleted user {user.name} ({user.email})") + return True + finally: + db.close() + + +def stats(): + from app.models.quiz import Quiz + from app.models.question import Question + from app.models.attempt import QuizAttempt + from app.models.flashcard import FlashcardDeck, Flashcard + from app.models.pdf_document import PDFDocument + db = SessionLocal() + try: + users = db.query(User).count() + admins = db.query(User).filter(User.role == "admin").count() + mods = db.query(User).filter(User.role == "moderator").count() + quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None)).count() + course_quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.isnot(None)).count() + questions = db.query(Question).count() + attempts = db.query(QuizAttempt).filter(QuizAttempt.completed_at.isnot(None)).count() + in_progress = db.query(QuizAttempt).filter(QuizAttempt.completed_at.is_(None)).count() + decks = db.query(FlashcardDeck).count() + cards = db.query(Flashcard).count() + docs = db.query(PDFDocument).count() + + try: + from app.models.course import Course, CourseEnrollment + courses = db.query(Course).count() + enrollments = db.query(CourseEnrollment).count() + except Exception: + courses = enrollments = "N/A" + + print("Platform Statistics") + print("=" * 40) + print(f"Users: {users} ({admins} admin, {mods} moderator)") + print(f"Documents: {docs}") + print(f"Questions: {questions}") + print(f"Quizzes: {quizzes} (+{course_quizzes} course)") + print(f"Attempts: {attempts} completed, {in_progress} in-progress") + print(f"Flashcard decks: {decks} ({cards} cards)") + print(f"Courses: {courses}") + print(f"Enrollments: {enrollments}") + finally: + db.close() + + +def list_courses(): + from app.models.course import Course, CourseEnrollment + db = SessionLocal() + try: + courses = db.query(Course).order_by(Course.id).all() + print(f"{'ID':<5} {'Title':<35} {'Status':<12} {'Creator':<20} {'Enrolled':<10}") + print("-" * 82) + for c in courses: + creator = db.query(User).filter(User.id == c.user_id).first() + name = creator.name if creator else "?" + print(f"{c.id:<5} {(c.title or '-')[:34]:<35} {c.status:<12} {name[:19]:<20} {c.enrolled_count or 0:<10}") + print(f"\n{len(courses)} course(s)") + finally: + db.close() + + +def list_quizzes(show_all=False): + from app.models.quiz import Quiz + db = SessionLocal() + try: + q = db.query(Quiz) + if not show_all: + q = q.filter(Quiz.deleted_at.is_(None)) + quizzes = q.order_by(Quiz.id).all() + print(f"{'ID':<5} {'Title':<35} {'Qs':<5} {'Mode':<10} {'Type':<10} {'Status':<10}") + print("-" * 75) + for qz in quizzes: + qtype = "course" if qz.course_id else "standalone" + status = "deleted" if qz.deleted_at else ("shared" if qz.is_shared else "private") + print(f"{qz.id:<5} {(qz.title or '-')[:34]:<35} {qz.questions_count:<5} {qz.mode:<10} {qtype:<10} {status:<10}") + print(f"\n{len(quizzes)} quiz(zes)") + finally: + db.close() + + +def cleanup_redis(): + from app.config import settings + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + keys = r.keys("quiz_progress:*") + expired = 0 + for key in keys: + ttl = r.ttl(key) + if ttl == -1: # no expiry set — orphaned + r.delete(key) + expired += 1 + session_keys = r.keys("quiz_session:*") + for key in session_keys: + ttl = r.ttl(key) + if ttl == -1: + r.delete(key) + expired += 1 + print(f"Cleaned {expired} orphaned keys out of {len(keys) + len(session_keys)} total") + + +def export_users(): + db = SessionLocal() + try: + users = db.query(User).order_by(User.id).all() + out = io.StringIO() + writer = csv.writer(out) + writer.writerow(["id", "name", "email", "role", "created_at"]) + for u in users: + writer.writerow([u.id, u.name, u.email, u.role, u.created_at]) + print(out.getvalue()) + finally: + db.close() + + +COMMANDS = { + "list-users": (list_users, 0), + "reset-password": (lambda args: reset_password(args[0], args[1]), 2), + "set-role": (lambda args: set_role(args[0], args[1]), 2), + "verify-email": (lambda args: verify_email(args[0]), 1), + "delete-user": (lambda args: delete_user(args[0]), 1), + "stats": (stats, 0), + "list-courses": (list_courses, 0), + "list-quizzes": (lambda args: list_quizzes("--all" in args), 0), + "cleanup-redis": (cleanup_redis, 0), + "export-users": (export_users, 0), +} + + +if __name__ == "__main__": + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help", "help"): + print(__doc__) + sys.exit(0) + + cmd = args[0] + rest = args[1:] + + if cmd not in COMMANDS: + print(f"Unknown command: {cmd}") + print("Run with --help to see available commands") + sys.exit(1) + + fn, min_args = COMMANDS[cmd] + if len(rest) < min_args: + print(f"Error: '{cmd}' requires {min_args} argument(s), got {len(rest)}") + sys.exit(1) + + result = fn(rest) if min_args > 0 else fn() + if result is False: + sys.exit(1) diff --git a/backend/app/main.py b/backend/app/main.py index afd9073..b6b95c0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -196,6 +196,35 @@ def setup_pgvector(): ADD COLUMN IF NOT EXISTS question_category_id INTEGER REFERENCES question_categories(id) ON DELETE SET NULL """)) + # Course quiz flag + conn.execute(text(""" + ALTER TABLE quizzes + ADD COLUMN IF NOT EXISTS course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE + """)) + + conn.execute(text(""" + ALTER TABLE quizzes + ADD COLUMN IF NOT EXISTS max_attempts INTEGER + """)) + conn.execute(text(""" + ALTER TABLE quizzes + ADD COLUMN IF NOT EXISTS questions_per_attempt INTEGER + """)) + conn.execute(text(""" + ALTER TABLE quiz_attempts + ADD COLUMN IF NOT EXISTS selected_question_ids JSON + """)) + + # Question ownership + conn.execute(text(""" + ALTER TABLE questions + ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE SET NULL + """)) + conn.execute(text(""" + ALTER TABLE questions + ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 1 + """)) + # Fix email collation to avoid en_US.utf8 B-tree index corruption conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"')) @@ -321,6 +350,7 @@ def setup_pgvector(): """)) # Quiz sharing conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0")) + conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1")) # ── Course / LMS tables ────────────────────────────────────── conn.execute(text(""" CREATE TABLE IF NOT EXISTS courses ( diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 99ecb78..97caef2 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey +from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey, JSON from sqlalchemy.orm import relationship from app.database import Base @@ -16,6 +16,7 @@ class QuizAttempt(Base): total_questions = Column(Integer, default=0) started_at = Column(DateTime, default=datetime.utcnow) completed_at = Column(DateTime, nullable=True) + selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses quiz = relationship("Quiz", back_populates="attempts") user = relationship("User", back_populates="attempts") diff --git a/backend/app/models/question.py b/backend/app/models/question.py index 7f79cec..49f62e2 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -23,6 +23,8 @@ class Question(Base): explanation = Column(Text, nullable=True) page_reference = Column(Integer, nullable=True) image_path = Column(String, nullable=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it) embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries question_category = relationship("QuestionCategory", back_populates="questions", diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index 461f914..ab4c2f2 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -24,6 +24,10 @@ class Quiz(Base): deleted_at = Column(DateTime, nullable=True) # soft delete — null = active is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only) is_shared = Column(Integer, default=0) # 0=private, 1=shared (visible to other users) + course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=True) # set = course-only quiz, hidden from main page + max_attempts = Column(Integer, nullable=True) # null = unlimited + questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool + allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review section = relationship("Section", back_populates="quizzes") user = relationship("User", back_populates="quizzes") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index d56d7aa..05c52c4 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -36,6 +36,16 @@ def start_attempt( if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") + # Enforce max_attempts + if quiz.max_attempts: + completed_count = db.query(QuizAttempt).filter( + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + ).count() + if completed_count >= quiz.max_attempts: + raise HTTPException(status_code=403, detail="Maximum attempts reached for this quiz") + # Reuse the most recent incomplete attempt unless fresh=true if not fresh: existing = db.query(QuizAttempt).filter( @@ -54,10 +64,20 @@ def start_attempt( completed_at=None, ) + # Question pool: randomly select N questions if questions_per_attempt is set + import random + selected_ids = None + total_q = quiz.questions_count + if quiz.questions_per_attempt and quiz.questions_per_attempt < quiz.questions_count: + all_q_ids = [q.id for q in quiz.questions] + selected_ids = sorted(random.sample(all_q_ids, quiz.questions_per_attempt)) + total_q = quiz.questions_per_attempt + attempt = QuizAttempt( quiz_id=quiz_id, user_id=current_user.id, - total_questions=quiz.questions_count, + total_questions=total_q, + selected_question_ids=selected_ids, ) db.add(attempt) db.commit() @@ -110,21 +130,27 @@ def submit_attempt( is_correct=is_correct, )) + # Check if review is allowed (course quizzes may disallow) + quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() + is_course_quiz = quiz and quiz.course_id is not None + review_allowed = not is_course_quiz or (quiz.allow_review == 1) + # Build full review — include ALL questions, unanswered marked as incorrect answer_details = [] - for q in get_quiz_questions(db, attempt.quiz_id): - user_answer = submitted.get(q.id, "") - is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() - answer_details.append(AnswerDetail( - question_id=q.id, - question_text=q.question_text, - question_type=q.question_type, - options=q.options, - user_answer=user_answer, - correct_answer=q.correct_answer, - is_correct=is_correct, - explanation=q.explanation, - )) + if review_allowed: + for q in get_quiz_questions(db, attempt.quiz_id): + user_answer = submitted.get(q.id, "") + is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() + answer_details.append(AnswerDetail( + question_id=q.id, + question_text=q.question_text, + question_type=q.question_type, + options=q.options, + user_answer=user_answer, + correct_answer=q.correct_answer, + is_correct=is_correct, + explanation=q.explanation, + )) attempt.score = score attempt.completed_at = datetime.utcnow() @@ -157,6 +183,8 @@ def submit_attempt( started_at=attempt.started_at, completed_at=attempt.completed_at, answers=answer_details, + course_id=quiz.course_id if quiz else None, + allow_review=review_allowed, ) @@ -338,7 +366,7 @@ def delete_attempt( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Delete own attempt + answers + reminders for that quiz.""" + """Delete own attempt + answers + reminders for that quiz. Cannot delete course quiz attempts.""" attempt = db.query(QuizAttempt).filter( QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id, @@ -346,6 +374,11 @@ def delete_attempt( if not attempt: raise HTTPException(status_code=404, detail="Attempt not found") + # Block deletion of course quiz attempts + quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() + if quiz and quiz.course_id: + raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts") + quiz_id = attempt.quiz_id # Delete all attempts for this quiz by this user (wipe history) from app.models.reminder import ReminderSchedule @@ -387,11 +420,18 @@ def get_in_progress_attempts( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Return all incomplete (not yet submitted) attempts for this user.""" - incomplete = db.query(QuizAttempt).filter( - QuizAttempt.user_id == current_user.id, - QuizAttempt.completed_at.is_(None), - ).order_by(QuizAttempt.started_at.desc()).all() + """Return all incomplete (not yet submitted) attempts for this user. Excludes course quizzes.""" + incomplete = ( + db.query(QuizAttempt) + .join(Quiz, QuizAttempt.quiz_id == Quiz.id) + .filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.is_(None), + Quiz.course_id.is_(None), # exclude course quizzes + ) + .order_by(QuizAttempt.started_at.desc()) + .all() + ) result = [] for a in incomplete: @@ -411,11 +451,18 @@ def get_quiz_history( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Return per-quiz attempt history for the performance line graph.""" - completed = db.query(QuizAttempt).filter( - QuizAttempt.user_id == current_user.id, - QuizAttempt.completed_at.isnot(None), - ).order_by(QuizAttempt.completed_at).all() + """Return per-quiz attempt history for the performance line graph (excludes course quizzes).""" + completed = ( + db.query(QuizAttempt) + .join(Quiz, QuizAttempt.quiz_id == Quiz.id) + .filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + Quiz.course_id.is_(None), + ) + .order_by(QuizAttempt.completed_at) + .all() + ) # Group by quiz from collections import defaultdict @@ -446,16 +493,28 @@ def get_dashboard_stats( current_user: User = Depends(get_current_user), ): total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count() - # Count distinct quizzes the user has attempted (not created) - total_quizzes = db.query(QuizAttempt.quiz_id).filter( - QuizAttempt.user_id == current_user.id, - QuizAttempt.completed_at.isnot(None), - ).distinct().count() + # Count distinct quizzes the user has attempted (excludes course quizzes) + total_quizzes = ( + db.query(QuizAttempt.quiz_id) + .join(Quiz, QuizAttempt.quiz_id == Quiz.id) + .filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + Quiz.course_id.is_(None), + ) + .distinct().count() + ) - completed_attempts = db.query(QuizAttempt).filter( - QuizAttempt.user_id == current_user.id, - QuizAttempt.completed_at.isnot(None), - ).all() + completed_attempts = ( + db.query(QuizAttempt) + .join(Quiz, QuizAttempt.quiz_id == Quiz.id) + .filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + Quiz.course_id.is_(None), + ) + .all() + ) total_attempts = len(completed_attempts) avg_score = 0.0 @@ -502,21 +561,26 @@ def get_attempt( if not attempt: raise HTTPException(status_code=404, detail="Attempt not found") + quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() + is_course_quiz = quiz and quiz.course_id is not None + review_allowed = not is_course_quiz or (quiz.allow_review == 1) + # Show ALL questions in review, not just answered ones submitted_map = {ans.question_id: ans for ans in attempt.answers} answer_details = [] - for q in get_quiz_questions(db, attempt.quiz_id): - ans = submitted_map.get(q.id) - answer_details.append(AnswerDetail( - question_id=q.id, - question_text=q.question_text, - question_type=q.question_type, - options=q.options, - user_answer=ans.user_answer if ans else "", - correct_answer=q.correct_answer, - is_correct=ans.is_correct if ans else False, - explanation=q.explanation, - )) + if review_allowed: + for q in get_quiz_questions(db, attempt.quiz_id): + ans = submitted_map.get(q.id) + answer_details.append(AnswerDetail( + question_id=q.id, + question_text=q.question_text, + question_type=q.question_type, + options=q.options, + user_answer=ans.user_answer if ans else "", + correct_answer=q.correct_answer, + is_correct=ans.is_correct if ans else False, + explanation=q.explanation, + )) percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 return AttemptDetail( @@ -528,4 +592,6 @@ def get_attempt( started_at=attempt.started_at, completed_at=attempt.completed_at, answers=answer_details, + course_id=quiz.course_id if quiz else None, + allow_review=review_allowed, ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index af6c793..1a2c548 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,8 @@ +import hashlib import secrets from datetime import datetime, timedelta +import httpx from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from sqlalchemy.orm import Session @@ -19,6 +21,26 @@ from app.utils.auth import ( router = APIRouter() + +def _check_pwned_password(password: str) -> int | None: + """Check if password appears in Have I Been Pwned breach database. + Uses k-anonymity: only first 5 chars of SHA-1 hash are sent. + Returns breach count, 0 if clean, or None if check failed.""" + try: + sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() + prefix, suffix = sha1[:5], sha1[5:] + resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3) + if resp.status_code != 200: + return None + for line in resp.text.splitlines(): + hash_suffix, count = line.split(":") + if hash_suffix == suffix: + return int(count) + return 0 + except Exception: + return None # fail open — don't block registration if API is down + + def _check_login_rate_limit(client_ip: str): """Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis.""" try: @@ -107,6 +129,15 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: if len(user_data.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + # Check breached passwords (warn, don't block) + pwned_count = _check_pwned_password(user_data.password) + password_warning = None + if pwned_count and pwned_count > 0: + password_warning = ( + f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. " + "Consider changing it to something unique." + ) + email_normalized = user_data.email.lower().strip() existing = db.query(User).filter(User.email == email_normalized).first() if existing: @@ -135,11 +166,17 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: if is_first_user: # Auto-verified admin — log them in immediately access_token = create_access_token(data={"sub": user.email}) - return {"access_token": access_token, "token_type": "bearer"} + resp = {"access_token": access_token, "token_type": "bearer"} + if password_warning: + resp["password_warning"] = password_warning + return resp # Regular user — must verify email before logging in background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token) - return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."} + resp = {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."} + if password_warning: + resp["password_warning"] = password_warning + return resp @router.post("/login", response_model=Token) @@ -252,10 +289,17 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)): if not user: raise HTTPException(status_code=404, detail="User not found") + pwned_count = _check_pwned_password(data.new_password) user.hashed_password = get_password_hash(data.new_password) record.used = True db.commit() - return {"message": "Password reset successfully. You can now log in."} + resp = {"message": "Password reset successfully. You can now log in."} + if pwned_count and pwned_count > 0: + resp["password_warning"] = ( + f"Warning: this password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. " + "Consider changing it to something more unique." + ) + return resp @router.get("/me/settings") @@ -293,8 +337,9 @@ def get_me(current_user: User = Depends(get_current_user)): return current_user -@router.put("/me", response_model=UserResponse) +@router.put("/me") def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + password_warning = None if data.new_password: if not data.current_password: raise HTTPException(status_code=400, detail="Current password required to set a new one") @@ -302,6 +347,12 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U raise HTTPException(status_code=400, detail="Current password is incorrect") if len(data.new_password) < 8: raise HTTPException(status_code=400, detail="New password must be at least 8 characters") + pwned_count = _check_pwned_password(data.new_password) + if pwned_count and pwned_count > 0: + password_warning = ( + f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. " + "Consider changing it to something more unique." + ) current_user.hashed_password = get_password_hash(data.new_password) if data.name: @@ -309,4 +360,10 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U db.commit() db.refresh(current_user) - return current_user + resp = { + "id": current_user.id, "email": current_user.email, + "name": current_user.name, "role": current_user.role, + } + if password_warning: + resp["password_warning"] = password_warning + return resp diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py index 79c8234..bfb5e77 100644 --- a/backend/app/routers/courses.py +++ b/backend/app/routers/courses.py @@ -7,7 +7,7 @@ from datetime import datetime from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -16,6 +16,8 @@ from app.database import get_db from app.models.course import ( Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress, ) +from app.models.question import Question +from app.models.quiz import Quiz from app.models.user import User from app.utils.auth import get_current_user @@ -26,27 +28,25 @@ router = APIRouter() # ── Schemas ────────────────────────────────────────────────────────── class CourseCreate(BaseModel): - title: str - description: str | None = None + title: str = Field(..., min_length=1, max_length=300) + description: str | None = Field(None, max_length=5000) class CourseUpdate(BaseModel): - title: str | None = None - description: str | None = None - category: str | None = None - thumbnail_url: str | None = None + title: str | None = Field(None, max_length=300) + description: str | None = Field(None, max_length=5000) requires_subscription: int | None = None class ModuleCreate(BaseModel): - title: str - description: str | None = None + title: str = Field(..., min_length=1, max_length=300) + description: str | None = Field(None, max_length=2000) position: int | None = None class ModuleUpdate(BaseModel): - title: str | None = None - description: str | None = None + title: str | None = Field(None, max_length=300) + description: str | None = Field(None, max_length=2000) position: int | None = None @@ -55,17 +55,21 @@ class ModuleReorder(BaseModel): class LessonCreate(BaseModel): - title: str + title: str = Field(..., min_length=1, max_length=300) lesson_type: str = "video" # video, document, quiz, live content_text: str | None = None video_url: str | None = None + quiz_id: int | None = None + live_session_url: str | None = None + live_session_start: str | None = None + live_session_end: str | None = None duration_minutes: int | None = None is_required: int = 1 position: int | None = None class LessonUpdate(BaseModel): - title: str | None = None + title: str | None = Field(None, max_length=300) lesson_type: str | None = None content_text: str | None = None video_url: str | None = None @@ -73,6 +77,10 @@ class LessonUpdate(BaseModel): duration_minutes: int | None = None is_required: int | None = None position: int | None = None + quiz_id: int | None = None + live_session_url: str | None = None + live_session_start: str | None = None + live_session_end: str | None = None class ProgressUpdate(BaseModel): @@ -85,6 +93,7 @@ class AIGenerateRequest(BaseModel): prompt: str action: str = "generate" # generate, refine, summarize existing_content: str | None = None + model_id: str | None = None # optional override model # ── Helpers ────────────────────────────────────────────────────────── @@ -121,7 +130,9 @@ def create_course( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Create a new course (any authenticated user). Starts as draft.""" + """Create a new course (moderators and admins only). Starts as draft.""" + if not current_user.is_moderator: + raise HTTPException(status_code=403, detail="Only moderators and admins can create courses") course = Course( title=data.title, description=data.description, @@ -164,6 +175,13 @@ def list_courses( .all() ) + # Pre-fetch module counts + mod_counts = dict( + db.query(CourseModule.course_id, func.count(CourseModule.id)) + .group_by(CourseModule.course_id) + .all() + ) + user_cache: dict[int, str] = {} result = [] for course, enrolled in rows: @@ -180,13 +198,13 @@ def list_courses( "id": course.id, "title": course.title, "description": course.description, - "status": course.status, "thumbnail_path": course.thumbnail_path, "requires_subscription": course.requires_subscription, "user_id": course.user_id, "creator_name": user_cache[course.user_id], "enrolled_count": int(enrolled), + "module_count": mod_counts.get(course.id, 0), "created_at": course.created_at, "updated_at": course.updated_at, }) @@ -226,6 +244,23 @@ def list_published_courses( .all() ) + # Pre-fetch module counts + course_ids = [c.id for c, _ in rows] + mod_counts = dict( + db.query(CourseModule.course_id, func.count(CourseModule.id)) + .filter(CourseModule.course_id.in_(course_ids)) + .group_by(CourseModule.course_id) + .all() + ) if course_ids else {} + + # Check which courses current user is enrolled in + my_enrolled_ids = set( + db.query(CourseEnrollment.course_id) + .filter(CourseEnrollment.user_id == current_user.id, CourseEnrollment.course_id.in_(course_ids)) + .all() + ) + my_enrolled_ids = {row[0] for row in my_enrolled_ids} + user_cache: dict[int, str] = {} courses = [] for course, enrolled in rows: @@ -236,12 +271,13 @@ def list_published_courses( "id": course.id, "title": course.title, "description": course.description, - "thumbnail_path": course.thumbnail_path, "requires_subscription": course.requires_subscription, "user_id": course.user_id, "creator_name": user_cache[course.user_id], "enrolled_count": int(enrolled), + "module_count": mod_counts.get(course.id, 0), + "is_enrolled": course.id in my_enrolled_ids, "created_at": course.created_at, }) @@ -260,18 +296,28 @@ def my_courses( .all() ) + course_ids = [e.course_id for e in enrollments] + mod_counts = dict( + db.query(CourseModule.course_id, func.count(CourseModule.id)) + .filter(CourseModule.course_id.in_(course_ids)) + .group_by(CourseModule.course_id) + .all() + ) if course_ids else {} + result = [] for enrollment in enrollments: course = db.query(Course).filter(Course.id == enrollment.course_id).first() if not course: continue + owner = db.query(User).filter(User.id == course.user_id).first() result.append({ "id": course.id, "title": course.title, "description": course.description, - "thumbnail_path": course.thumbnail_path, "status": course.status, + "creator_name": owner.name if owner else "Unknown", + "module_count": mod_counts.get(course.id, 0), "enrollment_id": enrollment.id, "progress_pct": enrollment.progress_pct, "enrolled_at": enrollment.enrolled_at, @@ -281,6 +327,57 @@ def my_courses( return result +def _build_lesson_dict(les, db, user_id): + """Build lesson dict with quiz metadata if applicable.""" + d = { + "id": les.id, + "title": les.title, + "lesson_type": les.lesson_type, + "content_text": les.content_text, + "video_url": les.video_url, + "video_provider": les.video_provider, + "local_file_path": les.local_file_path, + "duration_minutes": les.duration_minutes, + "quiz_id": les.quiz_id, + "live_session_url": les.live_session_url, + "live_session_start": les.live_session_start, + "live_session_end": les.live_session_end, + "description": les.description, + "is_required": les.is_required, + "position": les.position, + "bbb_meeting_id": les.bbb_meeting_id, + } + # Add quiz info for quiz lessons + if les.quiz_id and les.lesson_type == "quiz": + from app.models.attempt import QuizAttempt + quiz = db.query(Quiz).filter(Quiz.id == les.quiz_id).first() + if quiz: + attempts = ( + db.query(QuizAttempt) + .filter(QuizAttempt.quiz_id == quiz.id, QuizAttempt.user_id == user_id) + .order_by(QuizAttempt.completed_at.desc()) + .all() + ) + d["quiz_title"] = quiz.title + d["quiz_allow_review"] = quiz.allow_review + d["quiz_mode"] = quiz.mode + d["quiz_time_limit"] = quiz.time_limit_minutes + d["quiz_max_attempts"] = quiz.max_attempts + d["quiz_questions_count"] = quiz.questions_count + d["quiz_questions_per_attempt"] = quiz.questions_per_attempt + d["quiz_attempts"] = [ + { + "id": a.id, + "score": a.score, + "total": a.total_questions, + "percentage": round(a.score / a.total_questions * 100) if a.total_questions else 0, + "completed_at": a.completed_at.isoformat() if a.completed_at else None, + } + for a in attempts + ] + return d + + @router.get("/{course_id}") def get_course( course_id: int, @@ -329,24 +426,7 @@ def get_course( "description": mod.description, "position": mod.position, "lessons": [ - { - "id": les.id, - "title": les.title, - "lesson_type": les.lesson_type, - "content_text": les.content_text, - "video_url": les.video_url, - "video_provider": les.video_provider, - "local_file_path": les.local_file_path, - "duration_minutes": les.duration_minutes, - "quiz_id": les.quiz_id, - "live_session_url": les.live_session_url, - "live_session_start": les.live_session_start, - "live_session_end": les.live_session_end, - "description": les.description, - "is_required": les.is_required, - "position": les.position, - "bbb_meeting_id": les.bbb_meeting_id, - } + _build_lesson_dict(les, db, current_user.id) for les in lessons ], }) @@ -375,7 +455,7 @@ def get_course( "lesson_progress": { lp.lesson_id: { "status": lp.status, - "time_spent": lp.time_spent, + "time_spent": lp.time_spent_seconds, "score": lp.score, "completed_at": lp.completed_at, } @@ -449,6 +529,20 @@ def publish_course( return {"id": course.id, "status": course.status} +@router.post("/{course_id}/unpublish") +def unpublish_course( + course_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Set course status back to draft (creator or admin).""" + course = _course_owner_or_admin(course_id, current_user, db) + course.status = "draft" + course.updated_at = datetime.utcnow() + db.commit() + return {"id": course.id, "status": course.status} + + @router.post("/{course_id}/thumbnail") async def upload_thumbnail( course_id: int, @@ -591,6 +685,112 @@ def reorder_modules( return {"reordered": True, "count": len(data.module_ids)} +class LessonReorder(BaseModel): + lesson_ids: list[int] + + +@router.put("/{course_id}/modules/{module_id}/lessons/reorder") +def reorder_lessons( + course_id: int, + module_id: int, + data: LessonReorder, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Reorder lessons within a module by providing list of lesson IDs in desired order.""" + _course_owner_or_admin(course_id, current_user, db) + for idx, lesson_id in enumerate(data.lesson_ids): + lesson = ( + db.query(CourseLesson) + .filter(CourseLesson.id == lesson_id, CourseLesson.module_id == module_id) + .first() + ) + if lesson: + lesson.position = idx + 1 + db.commit() + return {"reordered": True, "count": len(data.lesson_ids)} + + +# ── Course Quiz Creation ───────────────────────────────────────────── + +class CourseQuizCreate(BaseModel): + question_ids: list[int] + title: str | None = None + mode: str = "learning" # timed or learning + time_limit_minutes: int | None = None + max_attempts: int | None = None # null = unlimited + questions_per_attempt: int | None = None # null = all questions; set to pick random subset + allow_review: bool = True # whether students can review answers after submit + + +@router.post("/{course_id}/quiz") +def create_course_quiz( + course_id: int, + data: CourseQuizCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a course-only quiz by copying selected questions. The quiz and copies + are independent from the main question bank and quiz list.""" + _course_owner_or_admin(course_id, current_user, db) + + if not data.question_ids: + raise HTTPException(status_code=400, detail="Select at least one question") + + source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all() + if not source_questions: + raise HTTPException(status_code=404, detail="No valid questions found") + + # Preserve caller order + id_order = {qid: i for i, qid in enumerate(data.question_ids)} + source_questions.sort(key=lambda q: id_order.get(q.id, 999)) + + # Create quiz tied to this course + qpa = data.questions_per_attempt + if qpa and qpa > len(source_questions): + qpa = None # can't ask for more than the pool + + quiz = Quiz( + user_id=current_user.id, + title=data.title or f"Course Quiz ({len(source_questions)} questions)", + questions_count=len(source_questions), + mode=data.mode if data.mode in ("timed", "learning") else "learning", + time_limit_minutes=data.time_limit_minutes, + max_attempts=data.max_attempts, + questions_per_attempt=qpa, + allow_review=1 if data.allow_review else 0, + is_published=0, + is_shared=0, + course_id=course_id, + ) + db.add(quiz) + db.flush() + + # Copy each question (independent copies) + from app.models.quiz_question_link import QuizQuestionLink + for pos, sq in enumerate(source_questions): + copy = Question( + question_text=sq.question_text, + question_type=sq.question_type, + options=sq.options, + correct_answer=sq.correct_answer, + explanation=sq.explanation, + image_path=sq.image_path, + question_category_id=sq.question_category_id, + source_quiz_id=quiz.id, + user_id=current_user.id, + is_shared=0, + ) + db.add(copy) + db.flush() + db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=copy.id, position=pos)) + + quiz.questions_count = len(source_questions) + db.commit() + db.refresh(quiz) + return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count} + + # ── Lesson CRUD ────────────────────────────────────────────────────── @router.post("/{course_id}/modules/{module_id}/lessons") @@ -631,6 +831,10 @@ def create_lesson( content_text=data.content_text, video_url=data.video_url, video_provider=video_provider, + quiz_id=data.quiz_id, + live_session_url=data.live_session_url, + live_session_start=data.live_session_start, + live_session_end=data.live_session_end, duration_minutes=data.duration_minutes, is_required=data.is_required, position=position, @@ -751,6 +955,165 @@ async def upload_lesson_file( # ── Enrollment ─────────────────────────────────────────────────────── +@router.get("/{course_id}/enrollees") +def list_enrollees( + course_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all enrollees with progress and quiz scores. Creator or admin only.""" + course = _course_owner_or_admin(course_id, current_user, db) + + enrollments = ( + db.query(CourseEnrollment) + .filter(CourseEnrollment.course_id == course_id) + .all() + ) + + # Get all quiz lessons for this course + from app.models.attempt import QuizAttempt + quiz_lessons = ( + db.query(CourseLesson) + .join(CourseModule, CourseLesson.module_id == CourseModule.id) + .filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None)) + .all() + ) + quiz_ids = [l.quiz_id for l in quiz_lessons] + quiz_titles = {} + for l in quiz_lessons: + q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first() + quiz_titles[l.quiz_id] = q.title if q else l.title + + result = [] + for enrollment in enrollments: + user = db.query(User).filter(User.id == enrollment.user_id).first() + if not user: + continue + + # Lesson progress + lesson_progress = ( + db.query(CourseLessonProgress) + .filter(CourseLessonProgress.enrollment_id == enrollment.id) + .all() + ) + completed_count = sum(1 for lp in lesson_progress if lp.status == "completed") + + # Quiz scores + quiz_scores = [] + for qid in quiz_ids: + best = ( + db.query(QuizAttempt) + .filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)) + .order_by(QuizAttempt.score.desc()) + .first() + ) + if best: + quiz_scores.append({ + "quiz_id": qid, + "quiz_title": quiz_titles.get(qid, f"Quiz {qid}"), + "best_score": best.score, + "total": best.total_questions, + "percentage": round(best.score / best.total_questions * 100) if best.total_questions else 0, + "attempts": db.query(func.count(QuizAttempt.id)).filter( + QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None) + ).scalar(), + }) + + result.append({ + "user_id": enrollment.user_id, + "name": user.name, + "email": user.email, + "progress_pct": enrollment.progress_pct or 0, + "completed_lessons": completed_count, + "enrolled_at": enrollment.enrolled_at, + "completed_at": enrollment.completed_at, + "quiz_scores": quiz_scores, + }) + + return result + + +@router.get("/{course_id}/enrollees/export") +def export_enrollees_csv( + course_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Export enrollee data as CSV. Creator or admin only.""" + import csv as csv_mod + import io as io_mod + from fastapi.responses import StreamingResponse + from app.models.attempt import QuizAttempt + + course = _course_owner_or_admin(course_id, current_user, db) + + enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == course_id).all() + + quiz_lessons = ( + db.query(CourseLesson) + .join(CourseModule, CourseLesson.module_id == CourseModule.id) + .filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None)) + .all() + ) + quiz_ids = [l.quiz_id for l in quiz_lessons] + quiz_titles = {} + for l in quiz_lessons: + q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first() + quiz_titles[l.quiz_id] = q.title if q else l.title + + buf = io_mod.StringIO() + writer = csv_mod.writer(buf) + + # Header + header = ["Name", "Email", "Progress %", "Completed Lessons", "Enrolled Date", "Completed Date"] + for qid in quiz_ids: + header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Best %)") + header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Attempts)") + writer.writerow(header) + + for enrollment in enrollments: + user = db.query(User).filter(User.id == enrollment.user_id).first() + if not user: + continue + lp_count = db.query(func.count(CourseLessonProgress.id)).filter( + CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.status == "completed" + ).scalar() + + row = [ + user.name, user.email, + enrollment.progress_pct or 0, + lp_count, + enrollment.enrolled_at.strftime("%Y-%m-%d") if enrollment.enrolled_at else "", + enrollment.completed_at.strftime("%Y-%m-%d") if enrollment.completed_at else "", + ] + for qid in quiz_ids: + best = ( + db.query(QuizAttempt) + .filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)) + .order_by(QuizAttempt.score.desc()) + .first() + ) + if best: + row.append(round(best.score / best.total_questions * 100) if best.total_questions else 0) + row.append(db.query(func.count(QuizAttempt.id)).filter( + QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None) + ).scalar()) + else: + row.append("") + row.append(0) + writer.writerow(row) + + buf.seek(0) + # Add UTF-8 BOM for LibreOffice/Excel compatibility + csv_content = "\ufeff" + buf.getvalue() + filename = f"{course.title.replace(' ', '_')}_enrollees.csv" + return StreamingResponse( + iter([csv_content]), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + @router.post("/{course_id}/enroll") def enroll( course_id: int, @@ -868,7 +1231,7 @@ def update_progress( if data.status == "completed" and not progress.completed_at: progress.completed_at = datetime.utcnow() if data.time_spent is not None: - progress.time_spent = (progress.time_spent or 0) + data.time_spent + progress.time_spent_seconds = (progress.time_spent_seconds or 0) + data.time_spent if data.score is not None: progress.score = data.score @@ -904,7 +1267,7 @@ def update_progress( return { "lesson_id": lesson_id, "status": progress.status, - "time_spent": progress.time_spent, + "time_spent": progress.time_spent_seconds, "score": progress.score, "enrollment_progress_pct": enrollment.progress_pct, } @@ -925,11 +1288,23 @@ def ai_generate_standalone( from app.services.ai_service import get_model_for_task, _proxy_model import litellm - model_id, api_key = get_model_for_task(db, "teach") + if data.model_id: + model_id = data.model_id + # Look up API key for this specific model + from app.models.ai_model_config import AIModelConfig + config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first() + api_key = config.api_key if config else None + else: + model_id, api_key = get_model_for_task(db, "teach") + system_msg = ( "You are a medical education content specialist for pediatrics. " - "Generate high-quality lesson content in markdown format. " - "Include relevant medical information, key concepts, and learning objectives." + "Generate high-quality lesson content in clean standard markdown. " + "Use ## for section headings (never # — the title is separate). " + "Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, " + "and standard markdown tables (| col | col |). " + "Include learning objectives, key concepts, tables, and clinical pearls. " + "If the user provides existing content, you can see it and modify/extend it as instructed." ) user_msg = data.prompt if data.existing_content: @@ -942,6 +1317,8 @@ def ai_generate_standalone( ], "max_tokens": 4000} if api_key: kwargs["api_key"] = api_key + elif settings.LITELLM_API_KEY: + kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = litellm.completion(**kwargs) @@ -974,7 +1351,13 @@ def ai_generate_content( from app.services.ai_service import get_model_for_task, _proxy_model import litellm - model_id, api_key = get_model_for_task(db, "teach") + if data.model_id: + model_id = data.model_id + from app.models.ai_model_config import AIModelConfig + config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first() + api_key = config.api_key if config else None + else: + model_id, api_key = get_model_for_task(db, "teach") if data.action == "refine" and data.existing_content: system_msg = ( @@ -1000,8 +1383,12 @@ def ai_generate_content( else: system_msg = ( "You are a medical education content specialist for pediatrics. " - "Generate high-quality lesson content in markdown format. " - "Include relevant medical information, key concepts, and learning objectives." + "Generate high-quality lesson content in clean standard markdown. " + "Use ## for section headings (never # — the title is separate). " + "Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, " + "and standard markdown tables (| col | col |). " + "Include learning objectives, key concepts, tables, and clinical pearls. " + "If the user provides existing content, you can see it and modify/extend it as instructed." ) user_msg = ( f"Lesson title: {lesson.title}\n\n" @@ -1019,6 +1406,8 @@ def ai_generate_content( } if api_key: kwargs["api_key"] = api_key + elif settings.LITELLM_API_KEY: + kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE @@ -1105,14 +1494,24 @@ def bbb_join_meeting( if not lesson.bbb_meeting_id: raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson") + # Verify user is enrolled or is the course owner/admin + course = db.query(Course).filter(Course.id == course_id).first() + is_owner = course and (course.user_id == current_user.id or current_user.is_admin) + if not is_owner: + enrolled = db.query(CourseEnrollment).filter( + CourseEnrollment.course_id == course_id, + CourseEnrollment.user_id == current_user.id, + ).first() + if not enrolled: + raise HTTPException(status_code=403, detail="You are not enrolled in this course") + bbb_url = getattr(settings, "BBB_SERVER_URL", None) bbb_secret = getattr(settings, "BBB_SECRET", None) if not bbb_url or not bbb_secret: raise HTTPException(status_code=501, detail="BBB not configured") # Course owner/admin joins as moderator, others as attendee - course = db.query(Course).filter(Course.id == course_id).first() - is_moderator = course and (course.user_id == current_user.id or current_user.is_admin) + is_moderator = is_owner password = "mp" if is_moderator else "ap" params = urlencode({ diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index e518277..40aecc2 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -1,7 +1,13 @@ """Question bank — view, search, categorise, and create quizzes from individual questions.""" -from fastapi import APIRouter, Depends, HTTPException, Query +import csv +import io +import os +import re +import uuid +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form +from fastapi.responses import StreamingResponse from pydantic import BaseModel -from sqlalchemy import cast, String, or_, text as sa_text +from sqlalchemy import cast, String, or_, text as sa_text, func from sqlalchemy.orm import Session from app.database import get_db @@ -15,6 +21,84 @@ from app.utils.auth import get_current_user, require_moderator router = APIRouter() +@router.delete("/{question_id}", status_code=204) +def delete_question( + question_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a question. Moderators can delete any; regular users can delete their own.""" + question = db.query(Question).filter(Question.id == question_id).first() + if not question: + raise HTTPException(status_code=404, detail="Question not found") + is_mod = current_user.role in ("admin", "moderator") + if not is_mod: + # Regular users can only delete questions they created (no source quiz) + if question.source_quiz_id is not None: + raise HTTPException(status_code=403, detail="Only moderators can delete extracted questions") + db.delete(question) + db.commit() + + +class QuestionEdit(BaseModel): + question_text: str | None = None + question_type: str | None = None + options: list[str] | None = None + correct_answer: str | None = None + explanation: str | None = None + question_category_id: int | None = None + image_path: str | None = None + + +@router.patch("/{question_id}") +def edit_question( + question_id: int, + data: QuestionEdit, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Edit a question. Owner or moderator can edit.""" + question = db.query(Question).filter(Question.id == question_id).first() + if not question: + raise HTTPException(status_code=404, detail="Question not found") + is_mod = current_user.role in ("admin", "moderator") + if question.user_id != current_user.id and not is_mod: + raise HTTPException(status_code=403, detail="Not authorized to edit this question") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(question, field, value) + db.commit() + db.refresh(question) + return { + "id": question.id, + "question_text": question.question_text, + "question_type": question.question_type, + "options": question.options, + "correct_answer": question.correct_answer, + "explanation": question.explanation, + "question_category_id": question.question_category_id, + "image_path": question.image_path, + } + + +@router.patch("/{question_id}/share") +def toggle_question_share( + question_id: int, + shared: int = Query(..., description="1 = shared, 0 = private"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Toggle question sharing. Owner or admin can change.""" + question = db.query(Question).filter(Question.id == question_id).first() + if not question: + raise HTTPException(status_code=404, detail="Question not found") + is_admin = current_user.role in ("admin", "moderator") + if question.user_id != current_user.id and not is_admin: + raise HTTPException(status_code=403, detail="Not your question") + question.is_shared = 1 if shared else 0 + db.commit() + return {"id": question_id, "is_shared": question.is_shared} + + @router.patch("/{question_id}/category") def set_question_category( question_id: int, @@ -98,6 +182,7 @@ def get_question_bank( category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"), uncategorized: bool = Query(False), favorites_only: bool = Query(False), + my_questions: bool = Query(False, description="Show only questions created by current user"), tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"), search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid" limit: int = Query(50, le=200), @@ -106,7 +191,16 @@ def get_question_bank( current_user: User = Depends(get_current_user), ): """List all questions across all quizzes. Supports keyword filter and quiz filter.""" - query = db.query(Question) + if my_questions: + query = db.query(Question).filter(Question.user_id == current_user.id) + else: + query = db.query(Question).filter( + or_( + Question.is_shared == 1, + Question.is_shared.is_(None), # legacy questions without is_shared + Question.user_id == current_user.id, # always show own questions + ) + ) if quiz_id: query = query.filter(Question.source_quiz_id == quiz_id) @@ -216,11 +310,30 @@ def get_question_bank( "correct_answer": qu.correct_answer, "explanation": qu.explanation, "image_path": qu.image_path, + "user_id": qu.user_id, + "is_shared": qu.is_shared if qu.is_shared is not None else 1, }) return {"total": total, "questions": result} +def _strip_html(text: str) -> str: + """Remove HTML tags and decode entities, returning plain text.""" + if not text: + return text + # Remove tags + clean = re.sub(r"<[^>]+>", " ", text) + # Decode common entities + clean = clean.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", '"') + # Collapse whitespace + clean = re.sub(r"\s+", " ", clean).strip() + return clean + + +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "uploads", "questions") +os.makedirs(UPLOAD_DIR, exist_ok=True) + + class ManualQuestionCreate(BaseModel): question_text: str question_type: str = "mcq" @@ -228,6 +341,7 @@ class ManualQuestionCreate(BaseModel): correct_answer: str explanation: str | None = None question_category_id: int | None = None + image_path: str | None = None @router.post("/create") @@ -237,7 +351,8 @@ def create_question_manually( current_user: User = Depends(get_current_user), ): """Create a single question manually (not from PDF extraction).""" - if not data.question_text.strip(): + q_text = data.question_text.strip() + if not q_text: raise HTTPException(status_code=400, detail="Question text is required") if not data.correct_answer.strip(): raise HTTPException(status_code=400, detail="Correct answer is required") @@ -247,12 +362,15 @@ def create_question_manually( raise HTTPException(status_code=400, detail="Correct answer must be one of the options") question = Question( - question_text=data.question_text.strip(), + question_text=q_text, question_type=data.question_type, options=data.options, correct_answer=data.correct_answer.strip(), explanation=data.explanation, question_category_id=data.question_category_id, + image_path=data.image_path, + user_id=current_user.id, + is_shared=1, ) db.add(question) db.commit() @@ -272,9 +390,46 @@ def create_question_manually( "question_type": question.question_type, "options": question.options, "correct_answer": question.correct_answer, + "image_path": question.image_path, } +@router.post("/upload-image") +def upload_question_image( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload an image for use in questions. Returns the relative path.""" + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "png" + if ext not in ("png", "jpg", "jpeg", "gif", "webp", "svg"): + raise HTTPException(status_code=400, detail="Unsupported image format") + filename = f"{uuid.uuid4().hex[:12]}.{ext}" + filepath = os.path.join(UPLOAD_DIR, filename) + with open(filepath, "wb") as f: + f.write(file.file.read()) + rel_path = f"questions/{filename}" + return {"image_path": rel_path, "url": f"/uploads/{rel_path}"} + + +@router.get("/images") +def list_question_images( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all unique question images for the image bank browser.""" + images = ( + db.query(Question.image_path) + .filter(Question.image_path.isnot(None), Question.image_path != "") + .distinct() + .limit(200) + .all() + ) + return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images] + + class CreateFromBankRequest(BaseModel): title: str question_ids: list[int] @@ -354,3 +509,150 @@ def bulk_set_question_category( ) db.commit() return {"updated": updated, "question_category_id": data.category_id} + + +@router.get("/import/sample") +def download_sample_csv(): + """Download a sample CSV template for question import.""" + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["question_text", "option_a", "option_b", "option_c", "option_d", "option_e", "correct_answer", "explanation", "category"]) + writer.writerow([ + "What is the most common cause of neonatal jaundice?", + "Physiological jaundice", "ABO incompatibility", "G6PD deficiency", "Breast milk jaundice", "", + "A", + "Physiological jaundice occurs in up to 60% of term neonates due to immature hepatic conjugation.", + "Neonatology", + ]) + writer.writerow([ + "Which vaccine is given at birth?", + "BCG", "Hepatitis B", "Both BCG and Hepatitis B", "OPV", "", + "C", + "Both BCG and Hepatitis B are given at birth per the WHO immunization schedule.", + "Immunology", + ]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=question_import_sample.csv"}, + ) + + +@router.post("/import/upload") +def upload_questions_csv( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Import questions from CSV or XLSX file. + + Expected columns: question_text, option_a, option_b, option_c, option_d, option_e, + correct_answer (A/B/C/D/E or full text), explanation, category + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + raw = file.file.read() + + rows = [] + if ext in ("xlsx", "xls"): + try: + import openpyxl + wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True) + ws = wb.active + header = None + for row in ws.iter_rows(values_only=True): + if header is None: + header = [str(c or "").strip().lower().replace(" ", "_") for c in row] + continue + rows.append(dict(zip(header, [str(c) if c is not None else "" for c in row]))) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {e}") + elif ext == "csv": + try: + text = raw.decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + for row in reader: + rows.append({k.strip().lower().replace(" ", "_"): (v or "").strip() for k, v in row.items()}) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {e}") + else: + raise HTTPException(status_code=400, detail="Unsupported file type. Use .csv or .xlsx") + + if not rows: + raise HTTPException(status_code=400, detail="File is empty or has no data rows") + + # Cache category lookups + cat_cache = {} + for cat in db.query(QuestionCategory).all(): + cat_cache[cat.name.lower()] = cat.id + + LETTER_MAP = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4} + created = [] + errors = [] + + for i, row in enumerate(rows, start=2): # row 2 = first data row + q_text = row.get("question_text", "").strip() + if not q_text: + errors.append(f"Row {i}: missing question_text") + continue + + options = [] + for key in ("option_a", "option_b", "option_c", "option_d", "option_e"): + val = row.get(key, "").strip() + if val: + options.append(val) + + correct_raw = row.get("correct_answer", "").strip() + correct_answer = "" + if correct_raw.lower() in LETTER_MAP and LETTER_MAP[correct_raw.lower()] < len(options): + correct_answer = options[LETTER_MAP[correct_raw.lower()]] + elif correct_raw in options: + correct_answer = correct_raw + else: + errors.append(f"Row {i}: invalid correct_answer '{correct_raw}'") + continue + + explanation = row.get("explanation", "").strip() or None + cat_name = row.get("category", "").strip() + cat_id = None + if cat_name: + cat_lower = cat_name.lower() + if cat_lower not in cat_cache: + new_cat = QuestionCategory(name=cat_name, user_id=current_user.id) + db.add(new_cat) + db.flush() + cat_cache[cat_lower] = new_cat.id + cat_id = cat_cache[cat_lower] + + question = Question( + question_text=q_text, + question_type="mcq" if len(options) >= 2 else "short_answer", + options=options if len(options) >= 2 else None, + correct_answer=correct_answer, + explanation=explanation, + question_category_id=cat_id, + ) + db.add(question) + created.append(question) + + db.commit() + + # Embed in background + for q in created: + db.refresh(q) + try: + from app.services import embedding_service + embedding_service.embed_question(q) + except Exception: + pass + if created: + db.commit() + + return { + "imported": len(created), + "errors": errors[:20], # cap error list + "total_rows": len(rows), + } diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 4487870..d406dee 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -162,7 +162,7 @@ def search_quizzes( def _ensure_quiz(quiz_id: int, match_type: str): if quiz_id not in results: quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() - if not quiz: + if not quiz or quiz.course_id is not None: return False results[quiz_id] = { "quiz_id": quiz.id, @@ -179,7 +179,7 @@ def search_quizzes( # ── Title search ───────────────────────────────────────────── if mode in ("title", "all"): - for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all(): + for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%"), Quiz.course_id.is_(None)).limit(30).all(): _ensure_quiz(quiz.id, "title") # ── Semantic (vector) search ────────────────────────────────── @@ -268,7 +268,7 @@ def list_quizzes( current_user: User = Depends(get_current_user), ): """List quizzes. Moderators see all; regular users only see published.""" - q = db.query(Quiz).filter(Quiz.deleted_at.is_(None)) + q = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None)) if not current_user.is_moderator: q = q.filter(Quiz.is_published == 1) return q.order_by(Quiz.created_at.desc()).all() @@ -278,17 +278,34 @@ def list_quizzes( def get_quiz( quiz_id: int, study: bool = Query(False), + attempt_id: int | None = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Get quiz for taking. study=true forces answers/explanations to be included.""" + """Get quiz for taking. study=true forces answers/explanations to be included. + attempt_id filters to the question subset selected for that attempt (question pool).""" quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") if study or quiz.mode == "learning": - return QuizLearningDetail.model_validate(quiz) - return QuizDetail.model_validate(quiz) + result = QuizLearningDetail.model_validate(quiz) + else: + result = QuizDetail.model_validate(quiz) + + # Filter questions if attempt has a question pool selection + if attempt_id: + from app.models.attempt import QuizAttempt + attempt = db.query(QuizAttempt).filter( + QuizAttempt.id == attempt_id, + QuizAttempt.user_id == current_user.id, + ).first() + if attempt and attempt.selected_question_ids: + sel = set(attempt.selected_question_ids) + result.questions = [q for q in result.questions if q.id in sel] + result.questions_count = len(result.questions) + + return result @router.patch("/{quiz_id}", response_model=QuizResponse) @@ -296,12 +313,14 @@ def update_quiz( quiz_id: int, data: QuizUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_moderator), + current_user: User = Depends(get_current_user), ): - """Update quiz metadata (title, etc.) — moderator only.""" + """Update quiz metadata (title, etc.) — owner or admin only.""" quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") + if quiz.user_id != current_user.id and current_user.role != "admin": + raise HTTPException(status_code=403, detail="Not authorized to edit this quiz") if data.title: quiz.title = data.title.strip() diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index 1b43e68..3a418fe 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -34,6 +34,8 @@ class AttemptResponse(BaseModel): percentage: float started_at: datetime completed_at: datetime | None + course_id: int | None = None + allow_review: bool = True class Config: from_attributes = True diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 8326de0..f291ca3 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -53,6 +53,7 @@ class QuizResponse(BaseModel): deleted_at: datetime | None = None is_published: int = 1 is_shared: int = 0 + questions_per_attempt: int | None = None class Config: from_attributes = True diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 6b1a0d0..50213e4 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta +import bcrypt from fastapi import Depends, HTTPException, status, Request, Response from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt -from passlib.context import CryptContext from sqlalchemy.orm import Session from starlette.middleware.base import BaseHTTPMiddleware @@ -11,16 +11,15 @@ from app.config import settings from app.database import get_db from app.models.user import User -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) def get_password_hash(password: str) -> str: - return pwd_context.hash(password) + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: diff --git a/backend/requirements.txt b/backend/requirements.txt index af8258b..7ec55b6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,8 +4,7 @@ uvicorn[standard]==0.27.1 sqlalchemy==2.0.27 alembic==1.13.1 python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -bcrypt==4.0.1 +bcrypt>=4.0.1 python-multipart==0.0.9 pydantic[email]==2.6.1 pydantic-settings==2.1.0 @@ -21,3 +20,4 @@ python-dotenv==1.0.1 httpx==0.27.0 boto3==1.34.69 pgvector==0.3.6 +openpyxl==3.1.2 diff --git a/backup-db.sh b/backup-db.sh new file mode 100755 index 0000000..473c653 --- /dev/null +++ b/backup-db.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Periodic PostgreSQL backup for PedsHub +# Keeps the last 14 daily backups, rotating old ones out. + +BACKUP_DIR="/home/danvics/docker/quiz/backups" +COMPOSE_DIR="/home/danvics/docker/quiz" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +FILENAME="pedquiz_${TIMESTAMP}.sql.gz" + +mkdir -p "$BACKUP_DIR" + +# Dump via the running postgres container +docker compose -f "$COMPOSE_DIR/docker-compose.yml" exec -T postgres \ + pg_dump -U pedquiz pedquiz | gzip > "$BACKUP_DIR/$FILENAME" + +if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/$FILENAME" ]; then + echo "[$(date)] Backup OK: $FILENAME ($(du -h "$BACKUP_DIR/$FILENAME" | cut -f1))" +else + echo "[$(date)] Backup FAILED" >&2 + rm -f "$BACKUP_DIR/$FILENAME" + exit 1 +fi + +# Rotate: keep last 14 backups +ls -1t "$BACKUP_DIR"/pedquiz_*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm -- diff --git a/docker-compose.yml b/docker-compose.yml index b2bf455..3b55ebd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,25 @@ services: - redis_data:/data restart: unless-stopped + db-backup: + image: prodrigestivill/postgres-backup-local:16 + restart: unless-stopped + environment: + POSTGRES_HOST: postgres + POSTGRES_DB: pedquiz + POSTGRES_USER: pedquiz + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + SCHEDULE: "@daily" + BACKUP_KEEP_DAYS: 14 + BACKUP_KEEP_WEEKS: 4 + BACKUP_KEEP_MONTHS: 6 + HEALTHCHECK_PORT: 8080 + volumes: + - ./backups:/backups + depends_on: + postgres: + condition: service_healthy + volumes: uploads_data: chroma_data: diff --git a/frontend/package.json b/frontend/package.json index ec487f1..78eb999 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,8 +16,18 @@ "react-router-dom": "^6.22.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", - "@tinymce/tinymce-react": "^5.1.1", - "tinymce": "^7.6.1" + "@milkdown/core": "^7.6.2", + "@milkdown/ctx": "^7.6.2", + "@milkdown/preset-commonmark": "^7.6.2", + "@milkdown/preset-gfm": "^7.6.2", + "@milkdown/plugin-listener": "^7.6.2", + "@milkdown/plugin-history": "^7.6.2", + "@milkdown/react": "^7.6.2", + "@milkdown/theme-nord": "^7.6.2", + "@milkdown/plugin-math": "^7.5.9", + "katex": "^0.16.11", + "remark-math": "^6.0.0", + "rehype-katex": "^7.0.1" }, "devDependencies": { "@types/react": "^18.2.55", diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 1607c9f..f031d35 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -94,7 +94,6 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/question-bank', label: 'Question Bank' }, { to: '/flashcards', label: 'Flashcards' }, { to: '/courses', label: 'Courses' }, - ...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []), { to: '/settings', label: '⚙ Settings' }, ] : [] diff --git a/frontend/src/components/RichEditor.jsx b/frontend/src/components/RichEditor.jsx index e397f56..33010f4 100644 --- a/frontend/src/components/RichEditor.jsx +++ b/frontend/src/components/RichEditor.jsx @@ -1,47 +1,114 @@ -import { useRef } from 'react' -import { Editor } from '@tinymce/tinymce-react' +import { useState, useRef, useCallback } from 'react' +import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core' +import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBulletListCommand, wrapInOrderedListCommand, wrapInBlockquoteCommand, insertHrCommand } from '@milkdown/preset-commonmark' +import { gfm, insertTableCommand, toggleStrikethroughCommand } from '@milkdown/preset-gfm' +import { history, undoCommand, redoCommand } from '@milkdown/plugin-history' +import { listener, listenerCtx } from '@milkdown/plugin-listener' +import { math } from '@milkdown/plugin-math' +import { nord } from '@milkdown/theme-nord' +import { Milkdown, MilkdownProvider, useEditor, useInstance } from '@milkdown/react' +import { callCommand } from '@milkdown/utils' +import '@milkdown/theme-nord/style.css' +import 'katex/dist/katex.min.css' -// Self-hosted TinyMCE — no API key needed -import 'tinymce/tinymce' -import 'tinymce/models/dom' -import 'tinymce/themes/silver' -import 'tinymce/icons/default' -import 'tinymce/skins/ui/oxide/skin.min.css' -import 'tinymce/plugins/lists' -import 'tinymce/plugins/link' -import 'tinymce/plugins/image' -import 'tinymce/plugins/table' -import 'tinymce/plugins/code' -import 'tinymce/plugins/fullscreen' -import 'tinymce/plugins/autolink' +function Toolbar() { + const [, getEditor] = useInstance() + const call = (cmd, payload) => { + const editor = getEditor() + if (editor) editor.action(callCommand(cmd, payload)) + } -export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) { - const editorRef = useRef(null) + const btn = { background: 'none', border: '1px solid #e2e8f0', borderRadius: 4, padding: '4px 8px', cursor: 'pointer', fontSize: '0.82rem', color: '#475569', lineHeight: 1 } + const sep = { width: 1, height: 20, background: '#e2e8f0', margin: '0 4px' } return ( - { editorRef.current = editor }} - value={value} - onEditorChange={(content) => onChange(content)} - init={{ - height, - menubar: false, - plugins: 'lists link image table code fullscreen autolink', - toolbar: 'undo redo | blocks | bold italic underline | bullist numlist | link image table | code fullscreen', - placeholder, - skin: false, - content_css: false, - content_style: ` - body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #e2e8f0; background: #1e293b; padding: 12px; } - a { color: #60a5fa; } - table { border-collapse: collapse; width: 100%; } - td, th { border: 1px solid #475569; padding: 6px 10px; } - img { max-width: 100%; } - `, - branding: false, - promotion: false, - license_key: 'gpl', - }} - /> +
+ + +
+ + + +
+ + + +
+ + +
+ ) +} + +function MilkdownEditor({ value, onChange, placeholder }) { + const isInternalChange = useRef(false) + const lastValue = useRef(value) + + useEditor((root) => + Editor.make() + .config(nord) + .config((ctx) => { + ctx.set(rootCtx, root) + ctx.set(defaultValueCtx, value || '') + const l = ctx.get(listenerCtx) + l.markdownUpdated((_, md) => { + isInternalChange.current = true + lastValue.current = md + onChange(md) + }) + }) + .use(commonmark) + .use(gfm) + .use(history) + .use(math) + .use(listener), + []) + + return +} + +export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) { + const [editorKey, setEditorKey] = useState(0) + const isInternalChange = useRef(false) + const lastSetValue = useRef(value) + + if (value !== lastSetValue.current && !isInternalChange.current) { + lastSetValue.current = value + setEditorKey(k => k + 1) + } + isInternalChange.current = false + + const handleChange = useCallback((md) => { + isInternalChange.current = true + lastSetValue.current = md + onChange(md) + }, [onChange]) + + return ( +
+ + + + + +
) } diff --git a/frontend/src/index.css b/frontend/src/index.css index b6471ca..4985aa2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -350,3 +350,20 @@ body { .quiz-sidebar { display: none; } .quiz-nav-toggle { display: inline-flex; } } + +/* ── Lesson content (markdown/HTML) ───────────────────────────── */ +.lesson-content h1, .lesson-content h2, .lesson-content h3, .lesson-content h4 { margin-top: 1.2em; margin-bottom: 0.5em; } +.lesson-content h2 { font-size: 1.3rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; } +.lesson-content h3 { font-size: 1.1rem; } +.lesson-content h4 { font-size: 0.95rem; } +.lesson-content p { margin: 0.6em 0; } +.lesson-content ul, .lesson-content ol { padding-left: 1.5em; margin: 0.5em 0; } +.lesson-content li { margin-bottom: 0.3em; } +.lesson-content table { width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 0.88rem; } +.lesson-content th, .lesson-content td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; } +.lesson-content th { background: var(--bg); font-weight: 600; } +.lesson-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; } +.lesson-content blockquote { border-left: 3px solid var(--primary); padding-left: 12px; margin: 1em 0; color: var(--text-muted); } +.lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; } +.lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; } +.lesson-content strong { font-weight: 700; } diff --git a/frontend/src/pages/AccountPage.jsx b/frontend/src/pages/AccountPage.jsx index c0e9200..685ced5 100644 --- a/frontend/src/pages/AccountPage.jsx +++ b/frontend/src/pages/AccountPage.jsx @@ -41,13 +41,16 @@ export default function AccountPage() { return } - await api.put('/auth/me', payload) + const res = await api.put('/auth/me', payload) + if (res.data.password_warning) { + setError(res.data.password_warning) + } setSuccess('Account updated successfully') setCurrentPassword('') setNewPassword('') setConfirmPassword('') // Refresh user info - const res = await api.get('/auth/me') + await api.get('/auth/me') // Update local token display by refreshing window.location.reload() } catch (err) { diff --git a/frontend/src/pages/CourseDetailPage.jsx b/frontend/src/pages/CourseDetailPage.jsx index f009d0d..59d166c 100644 --- a/frontend/src/pages/CourseDetailPage.jsx +++ b/frontend/src/pages/CourseDetailPage.jsx @@ -1,5 +1,12 @@ import { useState, useEffect } from 'react' -import { useParams, useNavigate } from 'react-router-dom' +import { useParams, useNavigate, Link } from 'react-router-dom' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeRaw from 'rehype-raw' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' +import { useAuth } from '../context/AuthContext' import api from '../api/client' const LESSON_ICONS = { @@ -45,10 +52,12 @@ export default function CourseDetailPage() { // Build completed/in-progress sets from enrollment lesson progress if available const completed = new Set() const inProgress = new Set() - if (data.enrollment?.lesson_progress) { - for (const lp of data.enrollment.lesson_progress) { - if (lp.status === 'completed') completed.add(lp.lesson_id) - else if (lp.status === 'in_progress') inProgress.add(lp.lesson_id) + if (data.my_enrollment?.lesson_progress) { + const lp = data.my_enrollment.lesson_progress + // lesson_progress is { lesson_id: { status, ... } } + for (const [lessonId, info] of Object.entries(lp)) { + if (info.status === 'completed') completed.add(parseInt(lessonId)) + else if (info.status === 'in_progress') inProgress.add(parseInt(lessonId)) } } setCompletedLessons(completed) @@ -124,8 +133,11 @@ export default function CourseDetailPage() { if (loading) return
if (!course) return
Course not found.
- const enrolled = !!course.enrollment - const progressPct = course.enrollment?.progress_pct || 0 + const { user } = useAuth() + const enrolled = !!course.my_enrollment + const isCreator = user && (course.user_id === user.id || user.role === 'admin') + const canView = enrolled || isCreator // creators can preview without enrolling + const progressPct = course.my_enrollment?.progress_pct || 0 return (
@@ -167,7 +179,7 @@ export default function CourseDetailPage() { {error &&
{error}
} - {!enrolled && ( + {!canView && (

Enroll in this course to access modules and lessons.

@@ -175,8 +187,14 @@ export default function CourseDetailPage() {
)} + {isCreator && !enrolled && ( +
+ Preview mode — you are viewing as the course creator. +
+ )} + {/* Modules accordion */} - {enrolled && course.modules && course.modules.length > 0 && ( + {canView && course.modules && course.modules.length > 0 && (
{course.modules.map(mod => { const isExpanded = !!expandedModules[mod.id] @@ -204,12 +222,12 @@ export default function CourseDetailPage() { {mod.title}
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''} - {lessons.length > 0 && ` \u00B7 ${completedCount}/${lessons.length} completed`} + {lessons.length > 0 && ` · ${completedCount}/${lessons.length} completed`}
{lessons.length > 0 && completedCount === lessons.length && ( - \u2713 Done + ✓ Done )}
@@ -244,14 +262,14 @@ export default function CourseDetailPage() { {lesson.title}
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''} - {lesson.lesson_type === 'live_session' && lesson.scheduled_at && ( - \u00B7 {new Date(lesson.scheduled_at).toLocaleString()} + {lesson.lesson_type === 'live_session' && lesson.live_session_start && ( + · {new Date(lesson.live_session_start).toLocaleString()} )}
- {isCompleted && \u2713} + {isCompleted && }
) @@ -264,12 +282,12 @@ export default function CourseDetailPage() {
)} - {enrolled && (!course.modules || course.modules.length === 0) && ( + {canView && (!course.modules || course.modules.length === 0) && (
This course has no modules yet.
)} {/* Lesson viewer */} - {enrolled && activeLesson && ( + {canView && activeLesson && (
@@ -283,12 +301,12 @@ export default function CourseDetailPage() {
{!completedLessons.has(activeLesson.id) && ( )} {completedLessons.has(activeLesson.id) && ( - \u2713 Completed + ✓ Completed )} @@ -298,8 +316,14 @@ export default function CourseDetailPage() { {loadingLesson &&
} {!loadingLesson && activeLesson.lesson_type === 'text' && ( -
- {activeLesson.content_text || 'No content available.'} +
+ {activeLesson.content_text ? ( + + {activeLesson.content_text} + + ) : ( +

No content available.

+ )}
)} @@ -327,23 +351,73 @@ export default function CourseDetailPage() { )} {!loadingLesson && activeLesson.lesson_type === 'quiz' && ( -
-

This lesson contains a quiz.

- {activeLesson.quiz_id ? ( - - ) : ( -

No quiz linked to this lesson.

+
+ {activeLesson.quiz_id ? (() => { + const attempts = activeLesson.quiz_attempts || [] + const maxAttempts = activeLesson.quiz_max_attempts + const attemptsLeft = maxAttempts ? maxAttempts - attempts.length : null + const canAttempt = !maxAttempts || attempts.length < maxAttempts + const lastAttempt = attempts[0] + return ( +
+ {activeLesson.quiz_title && ( +

{activeLesson.quiz_title}

+ )} +
+
+

+ {activeLesson.quiz_questions_per_attempt + ? `${activeLesson.quiz_questions_per_attempt} questions` + : `${activeLesson.quiz_questions_count || '?'} questions`} + {activeLesson.quiz_mode === 'timed' && activeLesson.quiz_time_limit ? ` · ${activeLesson.quiz_time_limit} min` : ''} + {activeLesson.quiz_mode === 'timed' ? ' · Exam mode' : ' · Study mode'} +

+ {maxAttempts && ( +

+ {attemptsLeft > 0 ? `${attemptsLeft} attempt${attemptsLeft !== 1 ? 's' : ''} remaining` : 'No attempts remaining'} +

+ )} +
+ {canAttempt && ( + + )} +
+ {attempts.length > 0 && ( +
+

Your Attempts

+ {attempts.map((a, i) => ( +
+ + {i === 0 ? 'Latest' : `Attempt ${attempts.length - i}`} + {a.completed_at ? ` · ${new Date(a.completed_at).toLocaleDateString()}` : ''} + +
+ = 75 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)' }}> + {a.score}/{a.total} ({a.percentage}%) + + {a.completed_at && activeLesson.quiz_allow_review !== 0 && ( + Review + )} +
+
+ ))} +
+ )} +
+ ) + })() : ( +

No quiz linked to this lesson.

)}
)} {!loadingLesson && activeLesson.lesson_type === 'live_session' && (
- {activeLesson.scheduled_at && ( + {activeLesson.live_session_start && (

- Scheduled: {new Date(activeLesson.scheduled_at).toLocaleString()} + Scheduled: {new Date(activeLesson.live_session_start).toLocaleString()}

)} {activeLesson.live_session_url ? ( diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx index 5560c14..964cf89 100644 --- a/frontend/src/pages/CourseEditorPage.jsx +++ b/frontend/src/pages/CourseEditorPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, lazy, Suspense } from 'react' +import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' @@ -6,6 +6,11 @@ import ConfirmButton from '../components/ConfirmButton' const RichEditor = lazy(() => import('../components/RichEditor')) +function stripHtml(html) { + if (!html) return '' + return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim() +} + const LESSON_TYPES = [ { value: 'text', label: 'Text / Markdown', icon: '📝' }, { value: 'video', label: 'Video', icon: '🎬' }, @@ -17,38 +22,52 @@ const LESSON_TYPES = [ const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝' // --- Question Bank Mini-Browser --- -function QuestionBankBrowser({ onQuizCreated }) { +function QuestionBankBrowser({ courseId, onQuizCreated }) { const [categories, setCategories] = useState([]) - const [categoryId, setCategoryId] = useState('') + const [filterCatIds, setFilterCatIds] = useState([]) + const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] }) + const [selectedTagIds, setSelectedTagIds] = useState([]) + const [showTags, setShowTags] = useState(false) const [search, setSearch] = useState('') const [questions, setQuestions] = useState([]) + const [total, setTotal] = useState(0) const [selected, setSelected] = useState(new Set()) const [loading, setLoading] = useState(false) const [creating, setCreating] = useState(false) const [error, setError] = useState('') + const debounceRef = useRef(null) useEffect(() => { api.get('/question-categories/').then(r => setCategories(r.data)).catch(() => {}) + api.get('/tags').then(r => setTags(r.data)).catch(() => {}) }, []) - const fetchQuestions = useCallback(async () => { + const fetchQuestions = useCallback(async (q = search, catIds = filterCatIds, tagIds = selectedTagIds) => { setLoading(true) try { - const params = { limit: 20 } - if (categoryId) params.category_id = categoryId - if (search.trim()) params.q = search.trim() + const params = { limit: 30 } + if (q.trim()) params.q = q.trim() + if (catIds.length > 0) params.category_ids = catIds.join(',') + if (tagIds.length > 0) params.tag_ids = tagIds.join(',') const res = await api.get('/questions/bank', { params }) setQuestions(res.data.questions || res.data || []) + setTotal(res.data.total || 0) } catch { setQuestions([]) } finally { setLoading(false) } - }, [categoryId, search]) + }, [search, filterCatIds, selectedTagIds]) + const catIdsKey = filterCatIds.join(',') + const tagIdsKey = selectedTagIds.join(',') useEffect(() => { - fetchQuestions() - }, [fetchQuestions]) + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => fetchQuestions(search, filterCatIds, selectedTagIds), 300) + return () => clearTimeout(debounceRef.current) + }, [search, catIdsKey, tagIdsKey]) + + const toggleTag = (tagId) => setSelectedTagIds(prev => prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]) const toggle = (qId) => { setSelected(prev => { @@ -58,15 +77,30 @@ function QuestionBankBrowser({ onQuizCreated }) { }) } + // Quiz settings + const [quizTitle, setQuizTitle] = useState('') + const [quizMode, setQuizMode] = useState('learning') + const [quizTimeLimit, setQuizTimeLimit] = useState('') + const [quizMaxAttempts, setQuizMaxAttempts] = useState('') + const [quizQuestionsPerAttempt, setQuizQuestionsPerAttempt] = useState('') + const [quizAllowReview, setQuizAllowReview] = useState(true) + const createQuiz = async () => { if (selected.size === 0) return setCreating(true) setError('') try { - const res = await api.post('/questions/from-bank', { + const res = await api.post(`/courses/${courseId}/quiz`, { question_ids: [...selected], + title: quizTitle.trim() || undefined, + mode: quizMode, + time_limit_minutes: quizTimeLimit ? parseInt(quizTimeLimit) : null, + max_attempts: quizMaxAttempts ? parseInt(quizMaxAttempts) : null, + questions_per_attempt: quizQuestionsPerAttempt ? parseInt(quizQuestionsPerAttempt) : null, + allow_review: quizAllowReview, }) - onQuizCreated(res.data.quiz_id || res.data.id) + setQuizTitle('') + onQuizCreated(res.data.id) } catch (err) { setError(err.response?.data?.detail || 'Failed to create quiz') } finally { @@ -74,51 +108,253 @@ function QuestionBankBrowser({ onQuizCreated }) { } } + // Inline add question + const [showAddQ, setShowAddQ] = useState(false) + const [newQ, setNewQ] = useState({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' }) + const [addingQ, setAddingQ] = useState(false) + + const submitNewQuestion = async () => { + const validOpts = newQ.options.filter(o => o.trim()) + if (!newQ.text.trim() || validOpts.length < 2 || newQ.correctIdx < 0) return + setAddingQ(true) + try { + const res = await api.post('/questions/create', { + question_text: newQ.text, + question_type: 'mcq', + options: validOpts, + correct_answer: newQ.options[newQ.correctIdx], + explanation: newQ.explanation || null, + }) + // Auto-select the new question + setSelected(prev => new Set([...prev, res.data.id])) + setNewQ({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' }) + setShowAddQ(false) + fetchQuestions() + } catch (err) { + setError(err.response?.data?.detail || 'Failed to add question') + } finally { + setAddingQ(false) + } + } + + const hasTags = tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0 + return ( -
-
- +
+ {/* Search */} +
+ 🔍 setSearch(e.target.value)} placeholder="Search questions..." - style={{ flex: 2, minWidth: 160 }} + style={{ width: '100%', padding: '9px 12px 9px 32px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.88rem', background: 'var(--input-bg)', color: 'var(--text)', boxSizing: 'border-box' }} />
-
- {loading &&
Loading...
} - {!loading && questions.length === 0 && ( -
No questions found.
- )} - {questions.map(q => ( - + {/* Category chips */} +
+ + {categories.map(cat => ( + ))} + {/* Active tag chips */} + {selectedTagIds.map(tid => { + const allTags = [...(tags.subjects || []), ...(tags.diseases || []), ...(tags.keywords || [])] + const tag = allTags.find(t => t.id === tid) + if (!tag) return null + const color = (tags.subjects || []).find(t => t.id === tid) ? '#8b5cf6' : (tags.diseases || []).find(t => t.id === tid) ? '#ef4444' : '#0ea5e9' + return ( + + ) + })} +
+ + {/* Tag browser toggle */} + {hasTags && ( +
+ + {showTags && ( +
+ {[ + { key: 'subjects', label: 'Subjects', color: '#8b5cf6', items: tags.subjects || [] }, + { key: 'diseases', label: 'Diseases', color: '#ef4444', items: tags.diseases || [] }, + { key: 'keywords', label: 'Keywords', color: '#0ea5e9', items: tags.keywords || [] }, + ].map(sec => { + if (sec.items.length === 0) return null + return ( +
+
{sec.label}
+
+ {sec.items.map(tag => { + const sel = selectedTagIds.includes(tag.id) + return ( + + ) + })} +
+
+ ) + })} +
+ )} +
+ )} + + {/* Results count */} +
+ {total > 0 ? `${total} questions found` : ''} {selected.size > 0 ? `· ${selected.size} selected` : ''} +
+ + {/* Question list */} +
+ {loading && questions.length === 0 &&
Loading...
} + {!loading && questions.length === 0 && ( +
No questions found.
+ )} + {questions.map(q => { + const isSelected = selected.has(q.id) + const plainText = stripHtml(q.question_text) + return ( +
toggle(q.id)} style={{ + padding: '12px 14px', marginBottom: 6, borderRadius: 10, cursor: 'pointer', + background: isSelected ? 'var(--option-sel-bg)' : 'var(--card-bg)', + border: `2px solid ${isSelected ? 'var(--primary)' : 'var(--border)'}`, + transition: 'all 0.15s', + }}> + {/* Top row: checkbox + category + source */} +
+ {}} + style={{ accentColor: 'var(--primary)', flexShrink: 0, width: 16, height: 16 }} /> + {q.question_category_name && ( + + {q.question_category_name} + + )} + {q.quiz_title && {q.quiz_title}} +
+ + {/* Question text */} +
+ {plainText.length > 220 ? plainText.slice(0, 220) + '...' : plainText} +
+ + {/* Options — each on its own row */} + {q.options && q.options.length > 0 && ( +
+ {q.options.map((opt, i) => ( +
+ {String.fromCharCode(65 + i)} + {opt.length > 80 ? opt.slice(0, 80) + '...' : opt} +
+ ))} +
+ )} +
+ ) + })}
{error &&
{error}
} - + {/* Add question inline */} + {showAddQ && ( +
+
Add New Question
+