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) <noreply@anthropic.com>
This commit is contained in:
parent
bc1a88a954
commit
8137d5b20c
37 changed files with 2574 additions and 509 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,3 +22,6 @@ dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
backend/.env.save
|
backend/.env.save
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
|
|
||||||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -14,7 +14,7 @@ PedsHub is a pediatric medical learning platform with an integrated LMS. Admins
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL 16 (pgvector) + Redis + Celery
|
- **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.
|
- **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)
|
- **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).
|
- **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)
|
├── ChromaDB (document page chunks for extraction context)
|
||||||
├── Redis (Celery broker, rate limits, settings, job progress, session locks)
|
├── Redis (Celery broker, rate limits, settings, job progress, session locks)
|
||||||
└── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration)
|
└── 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
|
## Course/LMS system
|
||||||
Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons.
|
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`
|
- **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate`
|
||||||
- **Status**: draft (creator-only) → published (visible to all) → archived
|
- **Status**: draft (creator-only) → published (visible to all) → archived
|
||||||
- **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder)
|
- **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`
|
- **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.
|
||||||
- **Any user can create quizzes** from the question bank (not just moderators). User quizzes are private by default (`is_published=0`, `is_shared=0`)
|
- **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
|
## Key directories
|
||||||
|
|
@ -74,6 +78,7 @@ frontend/src/
|
||||||
components/
|
components/
|
||||||
Navbar.jsx — Auth-aware nav with jobs badge
|
Navbar.jsx — Auth-aware nav with jobs badge
|
||||||
TeachChat.jsx — AI tutor drawer (lazy loaded, markdown/GFM tables)
|
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)
|
## Database tables (key ones)
|
||||||
|
|
@ -82,8 +87,8 @@ frontend/src/
|
||||||
| users | Accounts with role (admin/moderator/user) | — |
|
| users | Accounts with role (admin/moderator/user) | — |
|
||||||
| pdf_documents | Uploaded PDFs | user_id → users |
|
| pdf_documents | Uploaded PDFs | user_id → users |
|
||||||
| sections | Page ranges within a document | document_id → pdf_documents |
|
| sections | Page ranges within a document | document_id → pdf_documents |
|
||||||
| quizzes | Quiz metadata | section_id → sections (nullable), user_id → users |
|
| 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) |
|
| 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 |
|
| quiz_question_links | Quiz ↔ Question many-to-many | quiz_id, question_id |
|
||||||
| flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users |
|
| flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users |
|
||||||
| flashcards | Individual cards (front/back) | deck_id → flashcard_decks |
|
| 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 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 `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 `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.
|
||||||
|
|
|
||||||
264
backend/app/cli.py
Normal file
264
backend/app/cli.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""CLI management commands. Run inside the backend container:
|
||||||
|
|
||||||
|
docker compose exec backend python -m app.cli <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
list-users List all users
|
||||||
|
reset-password <email> <password> Reset a user's password
|
||||||
|
set-role <email> <role> Change user role (admin/moderator/user)
|
||||||
|
verify-email <email> Force-verify a user's email
|
||||||
|
delete-user <email> 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)
|
||||||
|
|
@ -196,6 +196,35 @@ def setup_pgvector():
|
||||||
ADD COLUMN IF NOT EXISTS question_category_id INTEGER
|
ADD COLUMN IF NOT EXISTS question_category_id INTEGER
|
||||||
REFERENCES question_categories(id) ON DELETE SET NULL
|
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
|
# 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"'))
|
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
|
||||||
|
|
||||||
|
|
@ -321,6 +350,7 @@ def setup_pgvector():
|
||||||
"""))
|
"""))
|
||||||
# Quiz sharing
|
# 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 is_shared INTEGER DEFAULT 0"))
|
||||||
|
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1"))
|
||||||
# ── Course / LMS tables ──────────────────────────────────────
|
# ── Course / LMS tables ──────────────────────────────────────
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS courses (
|
CREATE TABLE IF NOT EXISTS courses (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
@ -16,6 +16,7 @@ class QuizAttempt(Base):
|
||||||
total_questions = Column(Integer, default=0)
|
total_questions = Column(Integer, default=0)
|
||||||
started_at = Column(DateTime, default=datetime.utcnow)
|
started_at = Column(DateTime, default=datetime.utcnow)
|
||||||
completed_at = Column(DateTime, nullable=True)
|
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")
|
quiz = relationship("Quiz", back_populates="attempts")
|
||||||
user = relationship("User", back_populates="attempts")
|
user = relationship("User", back_populates="attempts")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class Question(Base):
|
||||||
explanation = Column(Text, nullable=True)
|
explanation = Column(Text, nullable=True)
|
||||||
page_reference = Column(Integer, nullable=True)
|
page_reference = Column(Integer, nullable=True)
|
||||||
image_path = Column(String, 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
|
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
|
||||||
|
|
||||||
question_category = relationship("QuestionCategory", back_populates="questions",
|
question_category = relationship("QuestionCategory", back_populates="questions",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class Quiz(Base):
|
||||||
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
|
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_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)
|
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")
|
section = relationship("Section", back_populates="quizzes")
|
||||||
user = relationship("User", back_populates="quizzes")
|
user = relationship("User", back_populates="quizzes")
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,16 @@ def start_attempt(
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
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
|
# Reuse the most recent incomplete attempt unless fresh=true
|
||||||
if not fresh:
|
if not fresh:
|
||||||
existing = db.query(QuizAttempt).filter(
|
existing = db.query(QuizAttempt).filter(
|
||||||
|
|
@ -54,10 +64,20 @@ def start_attempt(
|
||||||
completed_at=None,
|
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(
|
attempt = QuizAttempt(
|
||||||
quiz_id=quiz_id,
|
quiz_id=quiz_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
total_questions=quiz.questions_count,
|
total_questions=total_q,
|
||||||
|
selected_question_ids=selected_ids,
|
||||||
)
|
)
|
||||||
db.add(attempt)
|
db.add(attempt)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -110,21 +130,27 @@ def submit_attempt(
|
||||||
is_correct=is_correct,
|
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
|
# Build full review — include ALL questions, unanswered marked as incorrect
|
||||||
answer_details = []
|
answer_details = []
|
||||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
if review_allowed:
|
||||||
user_answer = submitted.get(q.id, "")
|
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||||
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
|
user_answer = submitted.get(q.id, "")
|
||||||
answer_details.append(AnswerDetail(
|
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
|
||||||
question_id=q.id,
|
answer_details.append(AnswerDetail(
|
||||||
question_text=q.question_text,
|
question_id=q.id,
|
||||||
question_type=q.question_type,
|
question_text=q.question_text,
|
||||||
options=q.options,
|
question_type=q.question_type,
|
||||||
user_answer=user_answer,
|
options=q.options,
|
||||||
correct_answer=q.correct_answer,
|
user_answer=user_answer,
|
||||||
is_correct=is_correct,
|
correct_answer=q.correct_answer,
|
||||||
explanation=q.explanation,
|
is_correct=is_correct,
|
||||||
))
|
explanation=q.explanation,
|
||||||
|
))
|
||||||
|
|
||||||
attempt.score = score
|
attempt.score = score
|
||||||
attempt.completed_at = datetime.utcnow()
|
attempt.completed_at = datetime.utcnow()
|
||||||
|
|
@ -157,6 +183,8 @@ def submit_attempt(
|
||||||
started_at=attempt.started_at,
|
started_at=attempt.started_at,
|
||||||
completed_at=attempt.completed_at,
|
completed_at=attempt.completed_at,
|
||||||
answers=answer_details,
|
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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
attempt = db.query(QuizAttempt).filter(
|
||||||
QuizAttempt.id == attempt_id,
|
QuizAttempt.id == attempt_id,
|
||||||
QuizAttempt.user_id == current_user.id,
|
QuizAttempt.user_id == current_user.id,
|
||||||
|
|
@ -346,6 +374,11 @@ def delete_attempt(
|
||||||
if not attempt:
|
if not attempt:
|
||||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
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
|
quiz_id = attempt.quiz_id
|
||||||
# Delete all attempts for this quiz by this user (wipe history)
|
# Delete all attempts for this quiz by this user (wipe history)
|
||||||
from app.models.reminder import ReminderSchedule
|
from app.models.reminder import ReminderSchedule
|
||||||
|
|
@ -387,11 +420,18 @@ def get_in_progress_attempts(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return all incomplete (not yet submitted) attempts for this user."""
|
"""Return all incomplete (not yet submitted) attempts for this user. Excludes course quizzes."""
|
||||||
incomplete = db.query(QuizAttempt).filter(
|
incomplete = (
|
||||||
QuizAttempt.user_id == current_user.id,
|
db.query(QuizAttempt)
|
||||||
QuizAttempt.completed_at.is_(None),
|
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||||
).order_by(QuizAttempt.started_at.desc()).all()
|
.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 = []
|
result = []
|
||||||
for a in incomplete:
|
for a in incomplete:
|
||||||
|
|
@ -411,11 +451,18 @@ def get_quiz_history(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return per-quiz attempt history for the performance line graph."""
|
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes)."""
|
||||||
completed = db.query(QuizAttempt).filter(
|
completed = (
|
||||||
QuizAttempt.user_id == current_user.id,
|
db.query(QuizAttempt)
|
||||||
QuizAttempt.completed_at.isnot(None),
|
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||||
).order_by(QuizAttempt.completed_at).all()
|
.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
|
# Group by quiz
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -446,16 +493,28 @@ def get_dashboard_stats(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
|
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
|
||||||
# Count distinct quizzes the user has attempted (not created)
|
# Count distinct quizzes the user has attempted (excludes course quizzes)
|
||||||
total_quizzes = db.query(QuizAttempt.quiz_id).filter(
|
total_quizzes = (
|
||||||
QuizAttempt.user_id == current_user.id,
|
db.query(QuizAttempt.quiz_id)
|
||||||
QuizAttempt.completed_at.isnot(None),
|
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||||
).distinct().count()
|
.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(
|
completed_attempts = (
|
||||||
QuizAttempt.user_id == current_user.id,
|
db.query(QuizAttempt)
|
||||||
QuizAttempt.completed_at.isnot(None),
|
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||||
).all()
|
.filter(
|
||||||
|
QuizAttempt.user_id == current_user.id,
|
||||||
|
QuizAttempt.completed_at.isnot(None),
|
||||||
|
Quiz.course_id.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
total_attempts = len(completed_attempts)
|
total_attempts = len(completed_attempts)
|
||||||
avg_score = 0.0
|
avg_score = 0.0
|
||||||
|
|
@ -502,21 +561,26 @@ def get_attempt(
|
||||||
if not attempt:
|
if not attempt:
|
||||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
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
|
# Show ALL questions in review, not just answered ones
|
||||||
submitted_map = {ans.question_id: ans for ans in attempt.answers}
|
submitted_map = {ans.question_id: ans for ans in attempt.answers}
|
||||||
answer_details = []
|
answer_details = []
|
||||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
if review_allowed:
|
||||||
ans = submitted_map.get(q.id)
|
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||||
answer_details.append(AnswerDetail(
|
ans = submitted_map.get(q.id)
|
||||||
question_id=q.id,
|
answer_details.append(AnswerDetail(
|
||||||
question_text=q.question_text,
|
question_id=q.id,
|
||||||
question_type=q.question_type,
|
question_text=q.question_text,
|
||||||
options=q.options,
|
question_type=q.question_type,
|
||||||
user_answer=ans.user_answer if ans else "",
|
options=q.options,
|
||||||
correct_answer=q.correct_answer,
|
user_answer=ans.user_answer if ans else "",
|
||||||
is_correct=ans.is_correct if ans else False,
|
correct_answer=q.correct_answer,
|
||||||
explanation=q.explanation,
|
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
|
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||||
return AttemptDetail(
|
return AttemptDetail(
|
||||||
|
|
@ -528,4 +592,6 @@ def get_attempt(
|
||||||
started_at=attempt.started_at,
|
started_at=attempt.started_at,
|
||||||
completed_at=attempt.completed_at,
|
completed_at=attempt.completed_at,
|
||||||
answers=answer_details,
|
answers=answer_details,
|
||||||
|
course_id=quiz.course_id if quiz else None,
|
||||||
|
allow_review=review_allowed,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -19,6 +21,26 @@ from app.utils.auth import (
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
def _check_login_rate_limit(client_ip: str):
|
||||||
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -107,6 +129,15 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
||||||
if len(user_data.password) < 8:
|
if len(user_data.password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
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()
|
email_normalized = user_data.email.lower().strip()
|
||||||
existing = db.query(User).filter(User.email == email_normalized).first()
|
existing = db.query(User).filter(User.email == email_normalized).first()
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -135,11 +166,17 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
||||||
if is_first_user:
|
if is_first_user:
|
||||||
# Auto-verified admin — log them in immediately
|
# Auto-verified admin — log them in immediately
|
||||||
access_token = create_access_token(data={"sub": user.email})
|
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
|
# Regular user — must verify email before logging in
|
||||||
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
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)
|
@router.post("/login", response_model=Token)
|
||||||
|
|
@ -252,10 +289,17 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)
|
user.hashed_password = get_password_hash(data.new_password)
|
||||||
record.used = True
|
record.used = True
|
||||||
db.commit()
|
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")
|
@router.get("/me/settings")
|
||||||
|
|
@ -293,8 +337,9 @@ def get_me(current_user: User = Depends(get_current_user)):
|
||||||
return 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)):
|
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 data.new_password:
|
||||||
if not data.current_password:
|
if not data.current_password:
|
||||||
raise HTTPException(status_code=400, detail="Current password required to set a new one")
|
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")
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
if len(data.new_password) < 8:
|
if len(data.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
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)
|
current_user.hashed_password = get_password_hash(data.new_password)
|
||||||
|
|
||||||
if data.name:
|
if data.name:
|
||||||
|
|
@ -309,4 +360,10 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(current_user)
|
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
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from datetime import datetime
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -16,6 +16,8 @@ from app.database import get_db
|
||||||
from app.models.course import (
|
from app.models.course import (
|
||||||
Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress,
|
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.models.user import User
|
||||||
from app.utils.auth import get_current_user
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
|
|
@ -26,27 +28,25 @@ router = APIRouter()
|
||||||
# ── Schemas ──────────────────────────────────────────────────────────
|
# ── Schemas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CourseCreate(BaseModel):
|
class CourseCreate(BaseModel):
|
||||||
title: str
|
title: str = Field(..., min_length=1, max_length=300)
|
||||||
description: str | None = None
|
description: str | None = Field(None, max_length=5000)
|
||||||
|
|
||||||
|
|
||||||
class CourseUpdate(BaseModel):
|
class CourseUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = Field(None, max_length=300)
|
||||||
description: str | None = None
|
description: str | None = Field(None, max_length=5000)
|
||||||
category: str | None = None
|
|
||||||
thumbnail_url: str | None = None
|
|
||||||
requires_subscription: int | None = None
|
requires_subscription: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModuleCreate(BaseModel):
|
class ModuleCreate(BaseModel):
|
||||||
title: str
|
title: str = Field(..., min_length=1, max_length=300)
|
||||||
description: str | None = None
|
description: str | None = Field(None, max_length=2000)
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModuleUpdate(BaseModel):
|
class ModuleUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = Field(None, max_length=300)
|
||||||
description: str | None = None
|
description: str | None = Field(None, max_length=2000)
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,17 +55,21 @@ class ModuleReorder(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class LessonCreate(BaseModel):
|
class LessonCreate(BaseModel):
|
||||||
title: str
|
title: str = Field(..., min_length=1, max_length=300)
|
||||||
lesson_type: str = "video" # video, document, quiz, live
|
lesson_type: str = "video" # video, document, quiz, live
|
||||||
content_text: str | None = None
|
content_text: str | None = None
|
||||||
video_url: 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
|
duration_minutes: int | None = None
|
||||||
is_required: int = 1
|
is_required: int = 1
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class LessonUpdate(BaseModel):
|
class LessonUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = Field(None, max_length=300)
|
||||||
lesson_type: str | None = None
|
lesson_type: str | None = None
|
||||||
content_text: str | None = None
|
content_text: str | None = None
|
||||||
video_url: str | None = None
|
video_url: str | None = None
|
||||||
|
|
@ -73,6 +77,10 @@ class LessonUpdate(BaseModel):
|
||||||
duration_minutes: int | None = None
|
duration_minutes: int | None = None
|
||||||
is_required: int | None = None
|
is_required: int | None = None
|
||||||
position: 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):
|
class ProgressUpdate(BaseModel):
|
||||||
|
|
@ -85,6 +93,7 @@ class AIGenerateRequest(BaseModel):
|
||||||
prompt: str
|
prompt: str
|
||||||
action: str = "generate" # generate, refine, summarize
|
action: str = "generate" # generate, refine, summarize
|
||||||
existing_content: str | None = None
|
existing_content: str | None = None
|
||||||
|
model_id: str | None = None # optional override model
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -121,7 +130,9 @@ def create_course(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
course = Course(
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
|
@ -164,6 +175,13 @@ def list_courses(
|
||||||
.all()
|
.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] = {}
|
user_cache: dict[int, str] = {}
|
||||||
result = []
|
result = []
|
||||||
for course, enrolled in rows:
|
for course, enrolled in rows:
|
||||||
|
|
@ -180,13 +198,13 @@ def list_courses(
|
||||||
"id": course.id,
|
"id": course.id,
|
||||||
"title": course.title,
|
"title": course.title,
|
||||||
"description": course.description,
|
"description": course.description,
|
||||||
|
|
||||||
"status": course.status,
|
"status": course.status,
|
||||||
"thumbnail_path": course.thumbnail_path,
|
"thumbnail_path": course.thumbnail_path,
|
||||||
"requires_subscription": course.requires_subscription,
|
"requires_subscription": course.requires_subscription,
|
||||||
"user_id": course.user_id,
|
"user_id": course.user_id,
|
||||||
"creator_name": user_cache[course.user_id],
|
"creator_name": user_cache[course.user_id],
|
||||||
"enrolled_count": int(enrolled),
|
"enrolled_count": int(enrolled),
|
||||||
|
"module_count": mod_counts.get(course.id, 0),
|
||||||
"created_at": course.created_at,
|
"created_at": course.created_at,
|
||||||
"updated_at": course.updated_at,
|
"updated_at": course.updated_at,
|
||||||
})
|
})
|
||||||
|
|
@ -226,6 +244,23 @@ def list_published_courses(
|
||||||
.all()
|
.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] = {}
|
user_cache: dict[int, str] = {}
|
||||||
courses = []
|
courses = []
|
||||||
for course, enrolled in rows:
|
for course, enrolled in rows:
|
||||||
|
|
@ -236,12 +271,13 @@ def list_published_courses(
|
||||||
"id": course.id,
|
"id": course.id,
|
||||||
"title": course.title,
|
"title": course.title,
|
||||||
"description": course.description,
|
"description": course.description,
|
||||||
|
|
||||||
"thumbnail_path": course.thumbnail_path,
|
"thumbnail_path": course.thumbnail_path,
|
||||||
"requires_subscription": course.requires_subscription,
|
"requires_subscription": course.requires_subscription,
|
||||||
"user_id": course.user_id,
|
"user_id": course.user_id,
|
||||||
"creator_name": user_cache[course.user_id],
|
"creator_name": user_cache[course.user_id],
|
||||||
"enrolled_count": int(enrolled),
|
"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,
|
"created_at": course.created_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -260,18 +296,28 @@ def my_courses(
|
||||||
.all()
|
.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 = []
|
result = []
|
||||||
for enrollment in enrollments:
|
for enrollment in enrollments:
|
||||||
course = db.query(Course).filter(Course.id == enrollment.course_id).first()
|
course = db.query(Course).filter(Course.id == enrollment.course_id).first()
|
||||||
if not course:
|
if not course:
|
||||||
continue
|
continue
|
||||||
|
owner = db.query(User).filter(User.id == course.user_id).first()
|
||||||
result.append({
|
result.append({
|
||||||
"id": course.id,
|
"id": course.id,
|
||||||
"title": course.title,
|
"title": course.title,
|
||||||
"description": course.description,
|
"description": course.description,
|
||||||
|
|
||||||
"thumbnail_path": course.thumbnail_path,
|
"thumbnail_path": course.thumbnail_path,
|
||||||
"status": course.status,
|
"status": course.status,
|
||||||
|
"creator_name": owner.name if owner else "Unknown",
|
||||||
|
"module_count": mod_counts.get(course.id, 0),
|
||||||
"enrollment_id": enrollment.id,
|
"enrollment_id": enrollment.id,
|
||||||
"progress_pct": enrollment.progress_pct,
|
"progress_pct": enrollment.progress_pct,
|
||||||
"enrolled_at": enrollment.enrolled_at,
|
"enrolled_at": enrollment.enrolled_at,
|
||||||
|
|
@ -281,6 +327,57 @@ def my_courses(
|
||||||
return result
|
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}")
|
@router.get("/{course_id}")
|
||||||
def get_course(
|
def get_course(
|
||||||
course_id: int,
|
course_id: int,
|
||||||
|
|
@ -329,24 +426,7 @@ def get_course(
|
||||||
"description": mod.description,
|
"description": mod.description,
|
||||||
"position": mod.position,
|
"position": mod.position,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
_build_lesson_dict(les, db, current_user.id)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
for les in lessons
|
for les in lessons
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -375,7 +455,7 @@ def get_course(
|
||||||
"lesson_progress": {
|
"lesson_progress": {
|
||||||
lp.lesson_id: {
|
lp.lesson_id: {
|
||||||
"status": lp.status,
|
"status": lp.status,
|
||||||
"time_spent": lp.time_spent,
|
"time_spent": lp.time_spent_seconds,
|
||||||
"score": lp.score,
|
"score": lp.score,
|
||||||
"completed_at": lp.completed_at,
|
"completed_at": lp.completed_at,
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +529,20 @@ def publish_course(
|
||||||
return {"id": course.id, "status": course.status}
|
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")
|
@router.post("/{course_id}/thumbnail")
|
||||||
async def upload_thumbnail(
|
async def upload_thumbnail(
|
||||||
course_id: int,
|
course_id: int,
|
||||||
|
|
@ -591,6 +685,112 @@ def reorder_modules(
|
||||||
return {"reordered": True, "count": len(data.module_ids)}
|
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 ──────────────────────────────────────────────────────
|
# ── Lesson CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/{course_id}/modules/{module_id}/lessons")
|
@router.post("/{course_id}/modules/{module_id}/lessons")
|
||||||
|
|
@ -631,6 +831,10 @@ def create_lesson(
|
||||||
content_text=data.content_text,
|
content_text=data.content_text,
|
||||||
video_url=data.video_url,
|
video_url=data.video_url,
|
||||||
video_provider=video_provider,
|
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,
|
duration_minutes=data.duration_minutes,
|
||||||
is_required=data.is_required,
|
is_required=data.is_required,
|
||||||
position=position,
|
position=position,
|
||||||
|
|
@ -751,6 +955,165 @@ async def upload_lesson_file(
|
||||||
|
|
||||||
# ── Enrollment ───────────────────────────────────────────────────────
|
# ── 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")
|
@router.post("/{course_id}/enroll")
|
||||||
def enroll(
|
def enroll(
|
||||||
course_id: int,
|
course_id: int,
|
||||||
|
|
@ -868,7 +1231,7 @@ def update_progress(
|
||||||
if data.status == "completed" and not progress.completed_at:
|
if data.status == "completed" and not progress.completed_at:
|
||||||
progress.completed_at = datetime.utcnow()
|
progress.completed_at = datetime.utcnow()
|
||||||
if data.time_spent is not None:
|
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:
|
if data.score is not None:
|
||||||
progress.score = data.score
|
progress.score = data.score
|
||||||
|
|
||||||
|
|
@ -904,7 +1267,7 @@ def update_progress(
|
||||||
return {
|
return {
|
||||||
"lesson_id": lesson_id,
|
"lesson_id": lesson_id,
|
||||||
"status": progress.status,
|
"status": progress.status,
|
||||||
"time_spent": progress.time_spent,
|
"time_spent": progress.time_spent_seconds,
|
||||||
"score": progress.score,
|
"score": progress.score,
|
||||||
"enrollment_progress_pct": enrollment.progress_pct,
|
"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
|
from app.services.ai_service import get_model_for_task, _proxy_model
|
||||||
import litellm
|
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 = (
|
system_msg = (
|
||||||
"You are a medical education content specialist for pediatrics. "
|
"You are a medical education content specialist for pediatrics. "
|
||||||
"Generate high-quality lesson content in markdown format. "
|
"Generate high-quality lesson content in clean standard markdown. "
|
||||||
"Include relevant medical information, key concepts, and learning objectives."
|
"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
|
user_msg = data.prompt
|
||||||
if data.existing_content:
|
if data.existing_content:
|
||||||
|
|
@ -942,6 +1317,8 @@ def ai_generate_standalone(
|
||||||
], "max_tokens": 4000}
|
], "max_tokens": 4000}
|
||||||
if api_key:
|
if api_key:
|
||||||
kwargs["api_key"] = api_key
|
kwargs["api_key"] = api_key
|
||||||
|
elif settings.LITELLM_API_KEY:
|
||||||
|
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||||
if settings.LITELLM_API_BASE:
|
if settings.LITELLM_API_BASE:
|
||||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||||
response = litellm.completion(**kwargs)
|
response = litellm.completion(**kwargs)
|
||||||
|
|
@ -974,7 +1351,13 @@ def ai_generate_content(
|
||||||
from app.services.ai_service import get_model_for_task, _proxy_model
|
from app.services.ai_service import get_model_for_task, _proxy_model
|
||||||
import litellm
|
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:
|
if data.action == "refine" and data.existing_content:
|
||||||
system_msg = (
|
system_msg = (
|
||||||
|
|
@ -1000,8 +1383,12 @@ def ai_generate_content(
|
||||||
else:
|
else:
|
||||||
system_msg = (
|
system_msg = (
|
||||||
"You are a medical education content specialist for pediatrics. "
|
"You are a medical education content specialist for pediatrics. "
|
||||||
"Generate high-quality lesson content in markdown format. "
|
"Generate high-quality lesson content in clean standard markdown. "
|
||||||
"Include relevant medical information, key concepts, and learning objectives."
|
"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 = (
|
user_msg = (
|
||||||
f"Lesson title: {lesson.title}\n\n"
|
f"Lesson title: {lesson.title}\n\n"
|
||||||
|
|
@ -1019,6 +1406,8 @@ def ai_generate_content(
|
||||||
}
|
}
|
||||||
if api_key:
|
if api_key:
|
||||||
kwargs["api_key"] = api_key
|
kwargs["api_key"] = api_key
|
||||||
|
elif settings.LITELLM_API_KEY:
|
||||||
|
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||||
if settings.LITELLM_API_BASE:
|
if settings.LITELLM_API_BASE:
|
||||||
kwargs["api_base"] = 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:
|
if not lesson.bbb_meeting_id:
|
||||||
raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson")
|
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_url = getattr(settings, "BBB_SERVER_URL", None)
|
||||||
bbb_secret = getattr(settings, "BBB_SECRET", None)
|
bbb_secret = getattr(settings, "BBB_SECRET", None)
|
||||||
if not bbb_url or not bbb_secret:
|
if not bbb_url or not bbb_secret:
|
||||||
raise HTTPException(status_code=501, detail="BBB not configured")
|
raise HTTPException(status_code=501, detail="BBB not configured")
|
||||||
|
|
||||||
# Course owner/admin joins as moderator, others as attendee
|
# Course owner/admin joins as moderator, others as attendee
|
||||||
course = db.query(Course).filter(Course.id == course_id).first()
|
is_moderator = is_owner
|
||||||
is_moderator = course and (course.user_id == current_user.id or current_user.is_admin)
|
|
||||||
password = "mp" if is_moderator else "ap"
|
password = "mp" if is_moderator else "ap"
|
||||||
|
|
||||||
params = urlencode({
|
params = urlencode({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
|
"""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 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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
@ -15,6 +21,84 @@ from app.utils.auth import get_current_user, require_moderator
|
||||||
router = APIRouter()
|
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")
|
@router.patch("/{question_id}/category")
|
||||||
def set_question_category(
|
def set_question_category(
|
||||||
question_id: int,
|
question_id: int,
|
||||||
|
|
@ -98,6 +182,7 @@ def get_question_bank(
|
||||||
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
||||||
uncategorized: bool = Query(False),
|
uncategorized: bool = Query(False),
|
||||||
favorites_only: 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)"),
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||||
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
||||||
limit: int = Query(50, le=200),
|
limit: int = Query(50, le=200),
|
||||||
|
|
@ -106,7 +191,16 @@ def get_question_bank(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
|
"""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:
|
if quiz_id:
|
||||||
query = query.filter(Question.source_quiz_id == quiz_id)
|
query = query.filter(Question.source_quiz_id == quiz_id)
|
||||||
|
|
@ -216,11 +310,30 @@ def get_question_bank(
|
||||||
"correct_answer": qu.correct_answer,
|
"correct_answer": qu.correct_answer,
|
||||||
"explanation": qu.explanation,
|
"explanation": qu.explanation,
|
||||||
"image_path": qu.image_path,
|
"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}
|
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):
|
class ManualQuestionCreate(BaseModel):
|
||||||
question_text: str
|
question_text: str
|
||||||
question_type: str = "mcq"
|
question_type: str = "mcq"
|
||||||
|
|
@ -228,6 +341,7 @@ class ManualQuestionCreate(BaseModel):
|
||||||
correct_answer: str
|
correct_answer: str
|
||||||
explanation: str | None = None
|
explanation: str | None = None
|
||||||
question_category_id: int | None = None
|
question_category_id: int | None = None
|
||||||
|
image_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
|
|
@ -237,7 +351,8 @@ def create_question_manually(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Create a single question manually (not from PDF extraction)."""
|
"""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")
|
raise HTTPException(status_code=400, detail="Question text is required")
|
||||||
if not data.correct_answer.strip():
|
if not data.correct_answer.strip():
|
||||||
raise HTTPException(status_code=400, detail="Correct answer is required")
|
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")
|
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
|
||||||
|
|
||||||
question = Question(
|
question = Question(
|
||||||
question_text=data.question_text.strip(),
|
question_text=q_text,
|
||||||
question_type=data.question_type,
|
question_type=data.question_type,
|
||||||
options=data.options,
|
options=data.options,
|
||||||
correct_answer=data.correct_answer.strip(),
|
correct_answer=data.correct_answer.strip(),
|
||||||
explanation=data.explanation,
|
explanation=data.explanation,
|
||||||
question_category_id=data.question_category_id,
|
question_category_id=data.question_category_id,
|
||||||
|
image_path=data.image_path,
|
||||||
|
user_id=current_user.id,
|
||||||
|
is_shared=1,
|
||||||
)
|
)
|
||||||
db.add(question)
|
db.add(question)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -272,9 +390,46 @@ def create_question_manually(
|
||||||
"question_type": question.question_type,
|
"question_type": question.question_type,
|
||||||
"options": question.options,
|
"options": question.options,
|
||||||
"correct_answer": question.correct_answer,
|
"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):
|
class CreateFromBankRequest(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
question_ids: list[int]
|
question_ids: list[int]
|
||||||
|
|
@ -354,3 +509,150 @@ def bulk_set_question_category(
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"updated": updated, "question_category_id": data.category_id}
|
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),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ def search_quizzes(
|
||||||
def _ensure_quiz(quiz_id: int, match_type: str):
|
def _ensure_quiz(quiz_id: int, match_type: str):
|
||||||
if quiz_id not in results:
|
if quiz_id not in results:
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
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
|
return False
|
||||||
results[quiz_id] = {
|
results[quiz_id] = {
|
||||||
"quiz_id": quiz.id,
|
"quiz_id": quiz.id,
|
||||||
|
|
@ -179,7 +179,7 @@ def search_quizzes(
|
||||||
|
|
||||||
# ── Title search ─────────────────────────────────────────────
|
# ── Title search ─────────────────────────────────────────────
|
||||||
if mode in ("title", "all"):
|
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")
|
_ensure_quiz(quiz.id, "title")
|
||||||
|
|
||||||
# ── Semantic (vector) search ──────────────────────────────────
|
# ── Semantic (vector) search ──────────────────────────────────
|
||||||
|
|
@ -268,7 +268,7 @@ def list_quizzes(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List quizzes. Moderators see all; regular users only see published."""
|
"""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:
|
if not current_user.is_moderator:
|
||||||
q = q.filter(Quiz.is_published == 1)
|
q = q.filter(Quiz.is_published == 1)
|
||||||
return q.order_by(Quiz.created_at.desc()).all()
|
return q.order_by(Quiz.created_at.desc()).all()
|
||||||
|
|
@ -278,17 +278,34 @@ def list_quizzes(
|
||||||
def get_quiz(
|
def get_quiz(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
study: bool = Query(False),
|
study: bool = Query(False),
|
||||||
|
attempt_id: int | None = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
|
||||||
if study or quiz.mode == "learning":
|
if study or quiz.mode == "learning":
|
||||||
return QuizLearningDetail.model_validate(quiz)
|
result = QuizLearningDetail.model_validate(quiz)
|
||||||
return QuizDetail.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)
|
@router.patch("/{quiz_id}", response_model=QuizResponse)
|
||||||
|
|
@ -296,12 +313,14 @@ def update_quiz(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
data: QuizUpdate,
|
data: QuizUpdate,
|
||||||
db: Session = Depends(get_db),
|
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()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
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:
|
if data.title:
|
||||||
quiz.title = data.title.strip()
|
quiz.title = data.title.strip()
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class AttemptResponse(BaseModel):
|
||||||
percentage: float
|
percentage: float
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
|
course_id: int | None = None
|
||||||
|
allow_review: bool = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class QuizResponse(BaseModel):
|
||||||
deleted_at: datetime | None = None
|
deleted_at: datetime | None = None
|
||||||
is_published: int = 1
|
is_published: int = 1
|
||||||
is_shared: int = 0
|
is_shared: int = 0
|
||||||
|
questions_per_attempt: int | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from fastapi import Depends, HTTPException, status, Request, Response
|
from fastapi import Depends, HTTPException, status, Request, Response
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
|
@ -11,16 +11,15 @@ from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
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:
|
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:
|
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ uvicorn[standard]==0.27.1
|
||||||
sqlalchemy==2.0.27
|
sqlalchemy==2.0.27
|
||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt>=4.0.1
|
||||||
bcrypt==4.0.1
|
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
pydantic[email]==2.6.1
|
pydantic[email]==2.6.1
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
|
|
@ -21,3 +20,4 @@ python-dotenv==1.0.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
boto3==1.34.69
|
boto3==1.34.69
|
||||||
pgvector==0.3.6
|
pgvector==0.3.6
|
||||||
|
openpyxl==3.1.2
|
||||||
|
|
|
||||||
25
backup-db.sh
Executable file
25
backup-db.sh
Executable file
|
|
@ -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 --
|
||||||
|
|
@ -66,6 +66,25 @@ services:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
chroma_data:
|
chroma_data:
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,18 @@
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"@tinymce/tinymce-react": "^5.1.1",
|
"@milkdown/core": "^7.6.2",
|
||||||
"tinymce": "^7.6.1"
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ export default function Navbar({ onSignIn, onRegister }) {
|
||||||
{ to: '/question-bank', label: 'Question Bank' },
|
{ to: '/question-bank', label: 'Question Bank' },
|
||||||
{ to: '/flashcards', label: 'Flashcards' },
|
{ to: '/flashcards', label: 'Flashcards' },
|
||||||
{ to: '/courses', label: 'Courses' },
|
{ to: '/courses', label: 'Courses' },
|
||||||
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
|
|
||||||
{ to: '/settings', label: '⚙ Settings' },
|
{ to: '/settings', label: '⚙ Settings' },
|
||||||
] : []
|
] : []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,114 @@
|
||||||
import { useRef } from 'react'
|
import { useState, useRef, useCallback } from 'react'
|
||||||
import { Editor } from '@tinymce/tinymce-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
|
function Toolbar() {
|
||||||
import 'tinymce/tinymce'
|
const [, getEditor] = useInstance()
|
||||||
import 'tinymce/models/dom'
|
const call = (cmd, payload) => {
|
||||||
import 'tinymce/themes/silver'
|
const editor = getEditor()
|
||||||
import 'tinymce/icons/default'
|
if (editor) editor.action(callCommand(cmd, payload))
|
||||||
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'
|
|
||||||
|
|
||||||
export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) {
|
const btn = { background: 'none', border: '1px solid #e2e8f0', borderRadius: 4, padding: '4px 8px', cursor: 'pointer', fontSize: '0.82rem', color: '#475569', lineHeight: 1 }
|
||||||
const editorRef = useRef(null)
|
const sep = { width: 1, height: 20, background: '#e2e8f0', margin: '0 4px' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '6px 10px', borderBottom: '1px solid #e2e8f0', background: '#f8fafc', flexWrap: 'wrap' }}>
|
||||||
onInit={(evt, editor) => { editorRef.current = editor }}
|
<button style={btn} onClick={() => call(undoCommand.key)} title="Undo (Ctrl+Z)">↩</button>
|
||||||
value={value}
|
<button style={btn} onClick={() => call(redoCommand.key)} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||||
onEditorChange={(content) => onChange(content)}
|
<div style={sep} />
|
||||||
init={{
|
<button style={{ ...btn, fontWeight: 700 }} onClick={() => call(toggleStrongCommand.key)} title="Bold (Ctrl+B)">B</button>
|
||||||
height,
|
<button style={{ ...btn, fontStyle: 'italic' }} onClick={() => call(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">I</button>
|
||||||
menubar: false,
|
<button style={{ ...btn, textDecoration: 'line-through' }} onClick={() => call(toggleStrikethroughCommand.key)} title="Strikethrough">S</button>
|
||||||
plugins: 'lists link image table code fullscreen autolink',
|
<div style={sep} />
|
||||||
toolbar: 'undo redo | blocks | bold italic underline | bullist numlist | link image table | code fullscreen',
|
<button style={btn} onClick={() => call(wrapInBulletListCommand.key)} title="Bullet list">• List</button>
|
||||||
placeholder,
|
<button style={btn} onClick={() => call(wrapInOrderedListCommand.key)} title="Numbered list">1. List</button>
|
||||||
skin: false,
|
<button style={btn} onClick={() => call(wrapInBlockquoteCommand.key)} title="Blockquote">> Quote</button>
|
||||||
content_css: false,
|
<div style={sep} />
|
||||||
content_style: `
|
<button style={btn} onClick={() => call(insertTableCommand.key)} title="Insert table">Table</button>
|
||||||
body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #e2e8f0; background: #1e293b; padding: 12px; }
|
<button style={btn} onClick={() => call(insertHrCommand.key)} title="Horizontal rule">― HR</button>
|
||||||
a { color: #60a5fa; }
|
</div>
|
||||||
table { border-collapse: collapse; width: 100%; }
|
)
|
||||||
td, th { border: 1px solid #475569; padding: 6px 10px; }
|
}
|
||||||
img { max-width: 100%; }
|
|
||||||
`,
|
function MilkdownEditor({ value, onChange, placeholder }) {
|
||||||
branding: false,
|
const isInternalChange = useRef(false)
|
||||||
promotion: false,
|
const lastValue = useRef(value)
|
||||||
license_key: 'gpl',
|
|
||||||
}}
|
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 <Milkdown />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="milkdown-wrapper" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
|
||||||
|
<MilkdownProvider key={editorKey}>
|
||||||
|
<Toolbar />
|
||||||
|
<MilkdownEditor value={value} onChange={handleChange} placeholder={placeholder} />
|
||||||
|
</MilkdownProvider>
|
||||||
|
<style>{`
|
||||||
|
.milkdown-wrapper .milkdown { min-height: ${height}px; }
|
||||||
|
.milkdown-wrapper .editor { padding: 16px 20px; outline: none; font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #1e293b; }
|
||||||
|
.milkdown-wrapper .editor > * { margin-top: 0; margin-bottom: 0.5em; }
|
||||||
|
.milkdown-wrapper .editor h2 { font-size: 1.3em; margin-top: 1.2em; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
|
||||||
|
.milkdown-wrapper .editor h3 { font-size: 1.1em; margin-top: 1em; }
|
||||||
|
.milkdown-wrapper .editor table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
|
.milkdown-wrapper .editor td, .milkdown-wrapper .editor th { border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }
|
||||||
|
.milkdown-wrapper .editor th { background: #f8fafc; font-weight: 600; }
|
||||||
|
.milkdown-wrapper .editor a { color: #2563eb; text-decoration: underline; }
|
||||||
|
.milkdown-wrapper .editor img { max-width: 100%; border-radius: 6px; }
|
||||||
|
.milkdown-wrapper .editor blockquote { border-left: 3px solid #2563eb; padding-left: 12px; color: #64748b; margin: 8px 0; }
|
||||||
|
.milkdown-wrapper .editor code { background: #f1f5f9; padding: 2px 5px; border-radius: 4px; font-size: 0.88em; }
|
||||||
|
.milkdown-wrapper .editor pre { background: #f1f5f9; padding: 12px; border-radius: 8px; overflow-x: auto; }
|
||||||
|
.milkdown-wrapper .editor hr { border: none; border-top: 1px solid #e2e8f0; margin: 1.5em 0; }
|
||||||
|
.milkdown-wrapper .editor ul, .milkdown-wrapper .editor ol { padding-left: 1.5em; }
|
||||||
|
.milkdown-wrapper .editor li { margin-bottom: 0.3em; }
|
||||||
|
.milkdown-wrapper .editor p:empty::before { content: '${placeholder}'; color: #94a3b8; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -350,3 +350,20 @@ body {
|
||||||
.quiz-sidebar { display: none; }
|
.quiz-sidebar { display: none; }
|
||||||
.quiz-nav-toggle { display: inline-flex; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,16 @@ export default function AccountPage() {
|
||||||
return
|
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')
|
setSuccess('Account updated successfully')
|
||||||
setCurrentPassword('')
|
setCurrentPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setConfirmPassword('')
|
setConfirmPassword('')
|
||||||
// Refresh user info
|
// Refresh user info
|
||||||
const res = await api.get('/auth/me')
|
await api.get('/auth/me')
|
||||||
// Update local token display by refreshing
|
// Update local token display by refreshing
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { useState, useEffect } from 'react'
|
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'
|
import api from '../api/client'
|
||||||
|
|
||||||
const LESSON_ICONS = {
|
const LESSON_ICONS = {
|
||||||
|
|
@ -45,10 +52,12 @@ export default function CourseDetailPage() {
|
||||||
// Build completed/in-progress sets from enrollment lesson progress if available
|
// Build completed/in-progress sets from enrollment lesson progress if available
|
||||||
const completed = new Set()
|
const completed = new Set()
|
||||||
const inProgress = new Set()
|
const inProgress = new Set()
|
||||||
if (data.enrollment?.lesson_progress) {
|
if (data.my_enrollment?.lesson_progress) {
|
||||||
for (const lp of data.enrollment.lesson_progress) {
|
const lp = data.my_enrollment.lesson_progress
|
||||||
if (lp.status === 'completed') completed.add(lp.lesson_id)
|
// lesson_progress is { lesson_id: { status, ... } }
|
||||||
else if (lp.status === 'in_progress') inProgress.add(lp.lesson_id)
|
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)
|
setCompletedLessons(completed)
|
||||||
|
|
@ -124,8 +133,11 @@ export default function CourseDetailPage() {
|
||||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||||
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
|
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
|
||||||
|
|
||||||
const enrolled = !!course.enrollment
|
const { user } = useAuth()
|
||||||
const progressPct = course.enrollment?.progress_pct || 0
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -167,7 +179,7 @@ export default function CourseDetailPage() {
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
{!enrolled && (
|
{!canView && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>Enroll in this course to access modules and lessons.</p>
|
<p>Enroll in this course to access modules and lessons.</p>
|
||||||
|
|
@ -175,8 +187,14 @@ export default function CourseDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isCreator && !enrolled && (
|
||||||
|
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--primary)', borderRadius: 8, padding: '8px 14px', marginBottom: 12, fontSize: '0.85rem', color: 'var(--primary)' }}>
|
||||||
|
Preview mode — you are viewing as the course creator.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modules accordion */}
|
{/* Modules accordion */}
|
||||||
{enrolled && course.modules && course.modules.length > 0 && (
|
{canView && course.modules && course.modules.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: activeLesson ? 16 : 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: activeLesson ? 16 : 0 }}>
|
||||||
{course.modules.map(mod => {
|
{course.modules.map(mod => {
|
||||||
const isExpanded = !!expandedModules[mod.id]
|
const isExpanded = !!expandedModules[mod.id]
|
||||||
|
|
@ -204,12 +222,12 @@ export default function CourseDetailPage() {
|
||||||
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
|
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
||||||
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
|
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
|
||||||
{lessons.length > 0 && ` \u00B7 ${completedCount}/${lessons.length} completed`}
|
{lessons.length > 0 && ` · ${completedCount}/${lessons.length} completed`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{lessons.length > 0 && completedCount === lessons.length && (
|
{lessons.length > 0 && completedCount === lessons.length && (
|
||||||
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>\u2713 Done</span>
|
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>✓ Done</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -244,14 +262,14 @@ export default function CourseDetailPage() {
|
||||||
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
|
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
|
||||||
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
|
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
|
||||||
{lesson.lesson_type === 'live_session' && lesson.scheduled_at && (
|
{lesson.lesson_type === 'live_session' && lesson.live_session_start && (
|
||||||
<span> \u00B7 {new Date(lesson.scheduled_at).toLocaleString()}</span>
|
<span> · {new Date(lesson.live_session_start).toLocaleString()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>\u2713</span>}
|
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>✓</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -264,12 +282,12 @@ export default function CourseDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{enrolled && (!course.modules || course.modules.length === 0) && (
|
{canView && (!course.modules || course.modules.length === 0) && (
|
||||||
<div className="card"><div className="empty-state">This course has no modules yet.</div></div>
|
<div className="card"><div className="empty-state">This course has no modules yet.</div></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lesson viewer */}
|
{/* Lesson viewer */}
|
||||||
{enrolled && activeLesson && (
|
{canView && activeLesson && (
|
||||||
<div className="card" style={{ marginTop: 16 }}>
|
<div className="card" style={{ marginTop: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -283,12 +301,12 @@ export default function CourseDetailPage() {
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{!completedLessons.has(activeLesson.id) && (
|
{!completedLessons.has(activeLesson.id) && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
|
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
|
||||||
Mark Complete \u2713
|
Mark Complete ✓
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{completedLessons.has(activeLesson.id) && (
|
{completedLessons.has(activeLesson.id) && (
|
||||||
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
|
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
|
||||||
\u2713 Completed
|
✓ Completed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => { setActiveLesson(null); setLessonContent(null) }}>Close</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => { setActiveLesson(null); setLessonContent(null) }}>Close</button>
|
||||||
|
|
@ -298,8 +316,14 @@ export default function CourseDetailPage() {
|
||||||
{loadingLesson && <div className="loading"><div className="spinner" /></div>}
|
{loadingLesson && <div className="loading"><div className="spinner" /></div>}
|
||||||
|
|
||||||
{!loadingLesson && activeLesson.lesson_type === 'text' && (
|
{!loadingLesson && activeLesson.lesson_type === 'text' && (
|
||||||
<div style={{ lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
|
<div className="lesson-content" style={{ lineHeight: 1.7 }}>
|
||||||
{activeLesson.content_text || 'No content available.'}
|
{activeLesson.content_text ? (
|
||||||
|
<Markdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeRaw, rehypeKatex]}>
|
||||||
|
{activeLesson.content_text}
|
||||||
|
</Markdown>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No content available.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -327,23 +351,73 @@ export default function CourseDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
|
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
|
||||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
<div style={{ padding: 20 }}>
|
||||||
<p style={{ marginBottom: 16, color: 'var(--text-muted)' }}>This lesson contains a quiz.</p>
|
{activeLesson.quiz_id ? (() => {
|
||||||
{activeLesson.quiz_id ? (
|
const attempts = activeLesson.quiz_attempts || []
|
||||||
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}`)}>
|
const maxAttempts = activeLesson.quiz_max_attempts
|
||||||
Start Quiz
|
const attemptsLeft = maxAttempts ? maxAttempts - attempts.length : null
|
||||||
</button>
|
const canAttempt = !maxAttempts || attempts.length < maxAttempts
|
||||||
) : (
|
const lastAttempt = attempts[0]
|
||||||
<p style={{ color: 'var(--text-muted)' }}>No quiz linked to this lesson.</p>
|
return (
|
||||||
|
<div>
|
||||||
|
{activeLesson.quiz_title && (
|
||||||
|
<h3 style={{ fontSize: '1.05rem', marginBottom: 8 }}>{activeLesson.quiz_title}</h3>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', margin: 0 }}>
|
||||||
|
{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'}
|
||||||
|
</p>
|
||||||
|
{maxAttempts && (
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
{attemptsLeft > 0 ? `${attemptsLeft} attempt${attemptsLeft !== 1 ? 's' : ''} remaining` : 'No attempts remaining'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canAttempt && (
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
|
||||||
|
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{attempts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '0.88rem', marginBottom: 8 }}>Your Attempts</h4>
|
||||||
|
{attempts.map((a, i) => (
|
||||||
|
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: '0.85rem' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{i === 0 ? 'Latest' : `Attempt ${attempts.length - i}`}
|
||||||
|
{a.completed_at ? ` · ${new Date(a.completed_at).toLocaleDateString()}` : ''}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 600, color: a.percentage >= 75 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)' }}>
|
||||||
|
{a.score}/{a.total} ({a.percentage}%)
|
||||||
|
</span>
|
||||||
|
{a.completed_at && activeLesson.quiz_allow_review !== 0 && (
|
||||||
|
<Link to={`/results/${a.id}?return_to=/courses/${courseId}`} style={{ fontSize: '0.78rem', color: 'var(--primary)', textDecoration: 'none' }}>Review</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})() : (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>No quiz linked to this lesson.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
|
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
|
||||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
{activeLesson.scheduled_at && (
|
{activeLesson.live_session_start && (
|
||||||
<p style={{ marginBottom: 12, fontSize: '0.95rem' }}>
|
<p style={{ marginBottom: 12, fontSize: '0.95rem' }}>
|
||||||
Scheduled: <strong>{new Date(activeLesson.scheduled_at).toLocaleString()}</strong>
|
Scheduled: <strong>{new Date(activeLesson.live_session_start).toLocaleString()}</strong>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{activeLesson.live_session_url ? (
|
{activeLesson.live_session_url ? (
|
||||||
|
|
|
||||||
|
|
@ -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 { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
@ -6,6 +6,11 @@ import ConfirmButton from '../components/ConfirmButton'
|
||||||
|
|
||||||
const RichEditor = lazy(() => import('../components/RichEditor'))
|
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 = [
|
const LESSON_TYPES = [
|
||||||
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
|
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
|
||||||
{ value: 'video', label: 'Video', icon: '🎬' },
|
{ value: 'video', label: 'Video', icon: '🎬' },
|
||||||
|
|
@ -17,38 +22,52 @@ const LESSON_TYPES = [
|
||||||
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
|
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
|
||||||
|
|
||||||
// --- Question Bank Mini-Browser ---
|
// --- Question Bank Mini-Browser ---
|
||||||
function QuestionBankBrowser({ onQuizCreated }) {
|
function QuestionBankBrowser({ courseId, onQuizCreated }) {
|
||||||
const [categories, setCategories] = useState([])
|
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 [search, setSearch] = useState('')
|
||||||
const [questions, setQuestions] = useState([])
|
const [questions, setQuestions] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
const [selected, setSelected] = useState(new Set())
|
const [selected, setSelected] = useState(new Set())
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/question-categories/').then(r => setCategories(r.data)).catch(() => {})
|
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)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = { limit: 20 }
|
const params = { limit: 30 }
|
||||||
if (categoryId) params.category_id = categoryId
|
if (q.trim()) params.q = q.trim()
|
||||||
if (search.trim()) params.q = search.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 })
|
const res = await api.get('/questions/bank', { params })
|
||||||
setQuestions(res.data.questions || res.data || [])
|
setQuestions(res.data.questions || res.data || [])
|
||||||
|
setTotal(res.data.total || 0)
|
||||||
} catch {
|
} catch {
|
||||||
setQuestions([])
|
setQuestions([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [categoryId, search])
|
}, [search, filterCatIds, selectedTagIds])
|
||||||
|
|
||||||
|
const catIdsKey = filterCatIds.join(',')
|
||||||
|
const tagIdsKey = selectedTagIds.join(',')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQuestions()
|
clearTimeout(debounceRef.current)
|
||||||
}, [fetchQuestions])
|
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) => {
|
const toggle = (qId) => {
|
||||||
setSelected(prev => {
|
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 () => {
|
const createQuiz = async () => {
|
||||||
if (selected.size === 0) return
|
if (selected.size === 0) return
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/questions/from-bank', {
|
const res = await api.post(`/courses/${courseId}/quiz`, {
|
||||||
question_ids: [...selected],
|
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) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Failed to create quiz')
|
setError(err.response?.data?.detail || 'Failed to create quiz')
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 14, marginTop: 8, background: 'var(--bg)' }}>
|
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginTop: 8, background: 'var(--bg)' }}>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
{/* Search */}
|
||||||
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} style={{ flex: 1, minWidth: 140 }}>
|
<div style={{ position: 'relative', marginBottom: 10 }}>
|
||||||
<option value="">All categories</option>
|
<span style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '0.85rem' }}>🔍</span>
|
||||||
{categories.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Search questions..."
|
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' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ maxHeight: 220, overflowY: 'auto', marginBottom: 10 }}>
|
{/* Category chips */}
|
||||||
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Loading...</div>}
|
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
{!loading && questions.length === 0 && (
|
<button className={`btn btn-sm ${filterCatIds.length === 0 ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No questions found.</div>
|
onClick={() => setFilterCatIds([])} style={{ fontSize: '0.72rem', padding: '3px 10px' }}>All</button>
|
||||||
)}
|
{categories.map(cat => (
|
||||||
{questions.map(q => (
|
<button key={cat.id} className={`btn btn-sm ${filterCatIds.includes(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
<label key={q.id} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 4px', cursor: 'pointer', fontSize: '0.85rem', borderBottom: '1px solid var(--border)' }}>
|
onClick={() => setFilterCatIds(prev => prev.includes(cat.id) ? prev.filter(c => c !== cat.id) : [...prev, cat.id])}
|
||||||
<input
|
style={{ fontSize: '0.72rem', padding: '3px 10px' }}>
|
||||||
type="checkbox"
|
{cat.name} <span style={{ opacity: 0.6 }}>({cat.question_count})</span>
|
||||||
checked={selected.has(q.id)}
|
</button>
|
||||||
onChange={() => toggle(q.id)}
|
|
||||||
style={{ marginTop: 3 }}
|
|
||||||
/>
|
|
||||||
<span style={{ flex: 1 }}>{q.question_text?.slice(0, 120) || `Question #${q.id}`}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
|
{/* 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 (
|
||||||
|
<button key={`tag-${tid}`} onClick={() => toggleTag(tid)}
|
||||||
|
style={{ background: color, color: '#fff', border: 'none', fontSize: '0.72rem', padding: '3px 10px', borderRadius: 12, cursor: 'pointer', fontWeight: 600 }}>
|
||||||
|
{tag.name} ✕
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag browser toggle */}
|
||||||
|
{hasTags && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => setShowTags(v => !v)}
|
||||||
|
style={{ fontSize: '0.72rem', padding: '3px 10px' }}>
|
||||||
|
Tags {showTags ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{showTags && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, marginTop: 8 }}>
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<div key={sec.key} style={{ background: 'var(--card-bg)', borderRadius: 8, padding: 8, border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontSize: '0.68rem', fontWeight: 700, color: sec.color, textTransform: 'uppercase', marginBottom: 4 }}>{sec.label}</div>
|
||||||
|
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
|
||||||
|
{sec.items.map(tag => {
|
||||||
|
const sel = selectedTagIds.includes(tag.id)
|
||||||
|
return (
|
||||||
|
<button key={tag.id} onClick={() => toggleTag(tag.id)}
|
||||||
|
style={{ display: 'block', width: '100%', textAlign: 'left', background: sel ? sec.color : 'transparent',
|
||||||
|
color: sel ? '#fff' : 'var(--text)', border: 'none', borderRadius: 4, padding: '2px 6px',
|
||||||
|
fontSize: '0.72rem', cursor: 'pointer', marginBottom: 1 }}>
|
||||||
|
{tag.name} <span style={{ opacity: 0.5 }}>({tag.count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: 6 }}>
|
||||||
|
{total > 0 ? `${total} questions found` : ''} {selected.size > 0 ? `· ${selected.size} selected` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question list */}
|
||||||
|
<div style={{ maxHeight: 420, overflowY: 'auto', marginBottom: 10 }}>
|
||||||
|
{loading && questions.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', padding: 12 }}>Loading...</div>}
|
||||||
|
{!loading && questions.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', padding: 12 }}>No questions found.</div>
|
||||||
|
)}
|
||||||
|
{questions.map(q => {
|
||||||
|
const isSelected = selected.has(q.id)
|
||||||
|
const plainText = stripHtml(q.question_text)
|
||||||
|
return (
|
||||||
|
<div key={q.id} onClick={() => 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 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<input type="checkbox" checked={isSelected} onChange={() => {}}
|
||||||
|
style={{ accentColor: 'var(--primary)', flexShrink: 0, width: 16, height: 16 }} />
|
||||||
|
{q.question_category_name && (
|
||||||
|
<span style={{ fontSize: '0.7rem', fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--primary)', color: '#fff' }}>
|
||||||
|
{q.question_category_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{q.quiz_title && <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{q.quiz_title}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question text */}
|
||||||
|
<div style={{ fontSize: '0.88rem', color: 'var(--text)', lineHeight: 1.6, marginBottom: q.options ? 8 : 0 }}>
|
||||||
|
{plainText.length > 220 ? plainText.slice(0, 220) + '...' : plainText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options — each on its own row */}
|
||||||
|
{q.options && q.options.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{q.options.map((opt, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'baseline', gap: 6,
|
||||||
|
fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: 1.4,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 700, fontSize: '0.72rem', color: 'var(--primary)',
|
||||||
|
background: 'var(--option-sel-bg)', borderRadius: 4, padding: '1px 5px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>{String.fromCharCode(65 + i)}</span>
|
||||||
|
<span>{opt.length > 80 ? opt.slice(0, 80) + '...' : opt}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 8, fontSize: '0.85rem' }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 8, fontSize: '0.85rem' }}>{error}</div>}
|
||||||
|
|
||||||
<button
|
{/* Add question inline */}
|
||||||
className="btn btn-primary btn-sm"
|
{showAddQ && (
|
||||||
onClick={createQuiz}
|
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--card-bg)' }}>
|
||||||
disabled={selected.size === 0 || creating}
|
<div style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: 8 }}>Add New Question</div>
|
||||||
>
|
<textarea value={newQ.text} onChange={e => setNewQ(q => ({ ...q, text: e.target.value }))}
|
||||||
{creating ? 'Creating...' : `Create Quiz from ${selected.size} Question${selected.size !== 1 ? 's' : ''}`}
|
placeholder="Question text..." rows={2}
|
||||||
</button>
|
style={{ width: '100%', padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem', resize: 'vertical', boxSizing: 'border-box', marginBottom: 8 }} />
|
||||||
|
{newQ.options.map((opt, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<button onClick={() => setNewQ(q => ({ ...q, correctIdx: i }))}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: '50%', border: `2px solid ${newQ.correctIdx === i ? 'var(--correct-fg)' : 'var(--border)'}`,
|
||||||
|
background: newQ.correctIdx === i ? 'var(--correct-fg)' : 'transparent', color: newQ.correctIdx === i ? '#fff' : 'var(--text-muted)',
|
||||||
|
fontSize: '0.7rem', fontWeight: 700, cursor: 'pointer', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{String.fromCharCode(65 + i)}
|
||||||
|
</button>
|
||||||
|
<input value={opt} onChange={e => { const opts = [...newQ.options]; opts[i] = e.target.value; setNewQ(q => ({ ...q, options: opts })) }}
|
||||||
|
placeholder={`Option ${String.fromCharCode(65 + i)}`}
|
||||||
|
style={{ flex: 1, padding: '6px 10px', border: `1px solid ${newQ.correctIdx === i ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6,
|
||||||
|
background: newQ.correctIdx === i ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<textarea value={newQ.explanation} onChange={e => setNewQ(q => ({ ...q, explanation: e.target.value }))}
|
||||||
|
placeholder="Explanation (optional)" rows={1}
|
||||||
|
style={{ width: '100%', padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem', resize: 'vertical', boxSizing: 'border-box', marginTop: 6 }} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={submitNewQuestion} disabled={addingQ || !newQ.text.trim() || newQ.correctIdx < 0}>
|
||||||
|
{addingQ ? 'Adding...' : 'Add Question'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setShowAddQ(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quiz settings */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10, padding: '10px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
|
<input type="text" value={quizTitle} onChange={e => setQuizTitle(e.target.value)}
|
||||||
|
placeholder="Quiz title (optional)" style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)', width: '100%' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<select value={quizMode} onChange={e => setQuizMode(e.target.value)}
|
||||||
|
style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
|
||||||
|
<option value="learning">Study mode</option>
|
||||||
|
<option value="timed">Timed exam</option>
|
||||||
|
</select>
|
||||||
|
{quizMode === 'timed' && (
|
||||||
|
<input type="number" min={1} value={quizTimeLimit} onChange={e => setQuizTimeLimit(e.target.value)}
|
||||||
|
placeholder="Time (min)" style={{ width: 90, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||||
|
)}
|
||||||
|
<input type="number" min={1} value={quizMaxAttempts} onChange={e => setQuizMaxAttempts(e.target.value)}
|
||||||
|
placeholder="Max attempts" title="Leave blank for unlimited"
|
||||||
|
style={{ width: 110, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||||
|
<input type="number" min={1} max={selected.size} value={quizQuestionsPerAttempt} onChange={e => setQuizQuestionsPerAttempt(e.target.value)}
|
||||||
|
placeholder={`Questions per try (${selected.size} pool)`} title="Random subset each attempt. Leave blank for all."
|
||||||
|
style={{ width: 180, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.82rem', color: 'var(--text)', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||||
|
<input type="checkbox" checked={quizAllowReview} onChange={e => setQuizAllowReview(e.target.checked)} />
|
||||||
|
Allow review
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={createQuiz}
|
||||||
|
disabled={selected.size === 0 || creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : `Create Quiz from ${selected.size} Question${selected.size !== 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
{!showAddQ && (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setShowAddQ(true)}>+ Add Question</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -139,8 +375,16 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [aiPrompt, setAiPrompt] = useState('')
|
const [aiPrompt, setAiPrompt] = useState('')
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
const [aiModels, setAiModels] = useState([])
|
||||||
|
const [aiModelId, setAiModelId] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/models/available?task=teach').then(r => {
|
||||||
|
setAiModels(r.data || [])
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!title.trim()) { setError('Title is required'); return }
|
if (!title.trim()) { setError('Title is required'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -185,13 +429,23 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
const endpoint = lesson?.id
|
const endpoint = lesson?.id
|
||||||
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
|
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
|
||||||
: `/courses/${courseId}/ai-generate`
|
: `/courses/${courseId}/ai-generate`
|
||||||
const res = await api.post(endpoint, {
|
const body = {
|
||||||
prompt: aiPrompt || `Write lesson content about: ${title}`,
|
prompt: aiPrompt || `Write lesson content about: ${title}`,
|
||||||
action,
|
action,
|
||||||
existing_content: action === 'refine' ? contentText : undefined,
|
existing_content: contentText || undefined,
|
||||||
})
|
}
|
||||||
|
if (aiModelId) body.model_id = aiModelId
|
||||||
|
const res = await api.post(endpoint, body)
|
||||||
if (res.data.generated_content) {
|
if (res.data.generated_content) {
|
||||||
setContentText(res.data.generated_content)
|
let content = res.data.generated_content
|
||||||
|
// Auto-set title if empty — use the prompt or first heading
|
||||||
|
if (!title.trim()) {
|
||||||
|
const headingMatch = content.match(/^#\s+(?:Lesson\s+Title:\s*)?(.+)/m)
|
||||||
|
setTitle(headingMatch ? headingMatch[1].trim() : aiPrompt.trim().slice(0, 80))
|
||||||
|
}
|
||||||
|
// Remove any "# Lesson Title: ..." line from content
|
||||||
|
content = content.replace(/^#\s+Lesson\s+Title:\s*.+\n+---\n*/m, '')
|
||||||
|
setContentText(content)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'AI generation failed')
|
setError(err.response?.data?.detail || 'AI generation failed')
|
||||||
|
|
@ -244,10 +498,17 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
type="text"
|
type="text"
|
||||||
value={aiPrompt}
|
value={aiPrompt}
|
||||||
onChange={e => setAiPrompt(e.target.value)}
|
onChange={e => setAiPrompt(e.target.value)}
|
||||||
placeholder="Describe what AI should write..."
|
placeholder="Describe what AI should write or modify..."
|
||||||
style={{ flex: 1, minWidth: 200, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}
|
style={{ flex: 1, minWidth: 180, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}
|
||||||
onKeyDown={e => e.key === 'Enter' && aiGenerate('generate')}
|
onKeyDown={e => e.key === 'Enter' && aiGenerate('generate')}
|
||||||
/>
|
/>
|
||||||
|
{aiModels.length > 0 && (
|
||||||
|
<select value={aiModelId} onChange={e => setAiModelId(e.target.value)}
|
||||||
|
style={{ padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem', maxWidth: 180 }}>
|
||||||
|
<option value="">Default model</option>
|
||||||
|
{aiModels.map(m => <option key={m.model_id} value={m.model_id}>{m.name || m.model_id}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => aiGenerate('generate')}
|
onClick={() => aiGenerate('generate')}
|
||||||
|
|
@ -257,20 +518,14 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
</button>
|
</button>
|
||||||
{contentText.trim() && (
|
{contentText.trim() && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => aiGenerate('refine')}
|
onClick={() => aiGenerate('refine')}
|
||||||
disabled={aiLoading}
|
disabled={aiLoading}
|
||||||
>
|
>
|
||||||
AI Refine
|
AI Refine
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!lesson?.id && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
|
||||||
Save the lesson first, then use AI Generate to create content.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -301,9 +556,9 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Upload Document</label>
|
<label>Upload Document</label>
|
||||||
<input type="file" accept=".pdf,.doc,.docx,.ppt,.pptx" onChange={e => setFile(e.target.files[0])} />
|
<input type="file" accept=".pdf,.doc,.docx,.ppt,.pptx" onChange={e => setFile(e.target.files[0])} />
|
||||||
{lesson?.file_name && !file && (
|
{lesson?.local_file_path && !file && (
|
||||||
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2, display: 'block' }}>
|
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2, display: 'block' }}>
|
||||||
Current: {lesson.file_name}
|
Current: {lesson.local_file_path}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,11 +570,19 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
<label>Quiz</label>
|
<label>Quiz</label>
|
||||||
{quizId ? (
|
{quizId ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ fontSize: '0.88rem' }}>Linked quiz #{quizId}</span>
|
<span style={{ fontSize: '0.88rem', color: 'var(--correct-fg)', fontWeight: 600 }}>
|
||||||
|
{lesson?.quiz_title || `Quiz #${quizId}`} linked
|
||||||
|
</span>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setQuizId(null)}>Change</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => setQuizId(null)}>Change</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<QuestionBankBrowser onQuizCreated={(id) => setQuizId(id)} />
|
<QuestionBankBrowser courseId={courseId} onQuizCreated={(id) => {
|
||||||
|
setQuizId(id)
|
||||||
|
// Auto-save quiz_id to lesson if lesson already exists
|
||||||
|
if (lesson?.id) {
|
||||||
|
api.put(`/courses/${courseId}/lessons/${lesson.id}`, { quiz_id: id }).catch(() => {})
|
||||||
|
}
|
||||||
|
}} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -418,15 +681,11 @@ export default function CourseEditorPage() {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const payload = { title: title.trim(), description: description.trim() }
|
await api.put(`/courses/${courseId}`, { title: title.trim(), description: description.trim() })
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('title', payload.title)
|
fd.append('file', thumbnail)
|
||||||
fd.append('description', payload.description)
|
await api.post(`/courses/${courseId}/thumbnail`, fd)
|
||||||
fd.append('thumbnail', thumbnail)
|
|
||||||
await api.put(`/courses/${courseId}`, fd)
|
|
||||||
} else {
|
|
||||||
await api.put(`/courses/${courseId}`, payload)
|
|
||||||
}
|
}
|
||||||
setSuccess('Course saved.')
|
setSuccess('Course saved.')
|
||||||
setThumbnail(null)
|
setThumbnail(null)
|
||||||
|
|
@ -438,15 +697,16 @@ export default function CourseEditorPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishCourse = async () => {
|
const togglePublish = async () => {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
setPublishing(true)
|
setPublishing(true)
|
||||||
|
const action = course.status === 'published' ? 'unpublish' : 'publish'
|
||||||
try {
|
try {
|
||||||
await api.post(`/courses/${courseId}/publish`)
|
await api.post(`/courses/${courseId}/${action}`)
|
||||||
setSuccess('Course published!')
|
setSuccess(action === 'publish' ? 'Course published!' : 'Course unpublished (draft).')
|
||||||
fetchCourse()
|
fetchCourse()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Failed to publish')
|
setError(err.response?.data?.detail || `Failed to ${action}`)
|
||||||
} finally {
|
} finally {
|
||||||
setPublishing(false)
|
setPublishing(false)
|
||||||
}
|
}
|
||||||
|
|
@ -563,6 +823,11 @@ export default function CourseEditorPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/courses')}>← Back to Courses</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${courseId}`)}>Preview Course</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{success && <div className="alert alert-success">{success}</div>}
|
{success && <div className="alert alert-success">{success}</div>}
|
||||||
|
|
||||||
|
|
@ -605,9 +870,9 @@ export default function CourseEditorPage() {
|
||||||
onChange={e => setThumbnail(e.target.files[0])}
|
onChange={e => setThumbnail(e.target.files[0])}
|
||||||
disabled={!isOwner}
|
disabled={!isOwner}
|
||||||
/>
|
/>
|
||||||
{course.thumbnail_url && !thumbnail && (
|
{course.thumbnail_path && !thumbnail && (
|
||||||
<img
|
<img
|
||||||
src={course.thumbnail_url}
|
src={course.thumbnail_path}
|
||||||
alt="Thumbnail"
|
alt="Thumbnail"
|
||||||
style={{ marginTop: 8, maxHeight: 120, borderRadius: 8, objectFit: 'cover' }}
|
style={{ marginTop: 8, maxHeight: 120, borderRadius: 8, objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -619,11 +884,10 @@ export default function CourseEditorPage() {
|
||||||
<button className="btn btn-primary" onClick={saveCourse} disabled={saving}>
|
<button className="btn btn-primary" onClick={saveCourse} disabled={saving}>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
{course.status !== 'published' && (
|
<button className={`btn ${course.status === 'published' ? 'btn-secondary' : 'btn-primary'}`}
|
||||||
<button className="btn btn-secondary" onClick={publishCourse} disabled={publishing}>
|
onClick={togglePublish} disabled={publishing} style={course.status === 'published' ? { color: 'var(--wrong-fg)' } : {}}>
|
||||||
{publishing ? 'Publishing...' : 'Publish'}
|
{publishing ? '...' : course.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -690,7 +954,7 @@ export default function CourseEditorPage() {
|
||||||
onChange={e => setEditModuleTitle(e.target.value)}
|
onChange={e => setEditModuleTitle(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') updateModule(mod.id); if (e.key === 'Escape') setEditingModuleId(null) }}
|
onKeyDown={e => { if (e.key === 'Enter') updateModule(mod.id); if (e.key === 'Escape') setEditingModuleId(null) }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.95rem', fontWeight: 600 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -763,7 +1027,7 @@ export default function CourseEditorPage() {
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
||||||
borderBottom: lesIdx < lessons.length - 1 ? '1px solid var(--border)' : 'none',
|
borderBottom: lesIdx < lessons.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '0.9rem' }}>{typeIcon(lesson.type)}</span>
|
<span style={{ fontSize: '0.9rem' }}>{typeIcon(lesson.lesson_type)}</span>
|
||||||
<span style={{ flex: 1, fontSize: '0.9rem' }}>
|
<span style={{ flex: 1, fontSize: '0.9rem' }}>
|
||||||
{lesson.title}
|
{lesson.title}
|
||||||
{lesson.is_required === false && (
|
{lesson.is_required === false && (
|
||||||
|
|
@ -771,7 +1035,7 @@ export default function CourseEditorPage() {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
{lesson.type}
|
{lesson.lesson_type}
|
||||||
{lesson.duration_minutes ? ` · ${lesson.duration_minutes}m` : ''}
|
{lesson.duration_minutes ? ` · ${lesson.duration_minutes}m` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
@ -831,6 +1095,87 @@ export default function CourseEditorPage() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Enrollees */}
|
||||||
|
{isOwner && <EnrolleesSection courseId={courseId} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnrolleesSection({ courseId }) {
|
||||||
|
const [enrollees, setEnrollees] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true)
|
||||||
|
api.get(`/courses/${courseId}/enrollees`).then(r => setEnrollees(r.data)).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>Enrollees</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setExpanded(v => !v); if (!expanded) load() }}>
|
||||||
|
{expanded ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/courses/${courseId}/enrollees/export`, { responseType: 'blob' })
|
||||||
|
const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url; a.download = 'enrollees.csv'; a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch {}
|
||||||
|
}}>Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{loading && <div className="loading"><div className="spinner" /></div>}
|
||||||
|
{!loading && enrollees.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.88rem' }}>No enrollees yet.</p>
|
||||||
|
)}
|
||||||
|
{enrollees.map(e => (
|
||||||
|
<div key={e.user_id} style={{ padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.92rem' }}>{e.name}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginLeft: 8 }}>{e.email}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: '0.85rem' }}>
|
||||||
|
<span style={{ color: e.progress_pct >= 100 ? 'var(--correct-fg)' : 'var(--text-muted)' }}>
|
||||||
|
{e.progress_pct}% complete
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{e.completed_lessons} lessons
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar" style={{ height: 4, marginTop: 6 }}>
|
||||||
|
<div className="fill" style={{ width: `${e.progress_pct}%` }} />
|
||||||
|
</div>
|
||||||
|
{e.quiz_scores && e.quiz_scores.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{e.quiz_scores.map(qs => (
|
||||||
|
<span key={qs.quiz_id} style={{
|
||||||
|
fontSize: '0.78rem', padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: qs.percentage >= 75 ? 'var(--correct-bg)' : qs.percentage >= 50 ? '#fef3c7' : 'var(--wrong-bg)',
|
||||||
|
color: qs.percentage >= 75 ? 'var(--correct-fg)' : qs.percentage >= 50 ? '#92400e' : 'var(--wrong-fg)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{qs.quiz_title}: {qs.percentage}% ({qs.attempts} attempt{qs.attempts !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
export default function CoursesPage() {
|
export default function CoursesPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
const [tab, setTab] = useState('browse')
|
const [tab, setTab] = useState('browse')
|
||||||
const [courses, setCourses] = useState([])
|
const [courses, setCourses] = useState([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
|
@ -16,7 +18,6 @@ export default function CoursesPage() {
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [newDescription, setNewDescription] = useState('')
|
const [newDescription, setNewDescription] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const { user } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
const LIMIT = 12
|
const LIMIT = 12
|
||||||
|
|
@ -75,8 +76,9 @@ export default function CoursesPage() {
|
||||||
|
|
||||||
const togglePublish = async (courseId, currentlyPublished) => {
|
const togglePublish = async (courseId, currentlyPublished) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/courses/${courseId}`, { is_published: !currentlyPublished })
|
const action = currentlyPublished ? 'unpublish' : 'publish'
|
||||||
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, is_published: !currentlyPublished } : c))
|
await api.post(`/courses/${courseId}/${action}`)
|
||||||
|
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, status: currentlyPublished ? 'draft' : 'published' } : c))
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +115,7 @@ export default function CoursesPage() {
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse</button>
|
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse</button>
|
||||||
<button className={`btn btn-sm ${tab === 'enrolled' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('enrolled')}>Enrolled</button>
|
<button className={`btn btn-sm ${tab === 'enrolled' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('enrolled')}>Enrolled</button>
|
||||||
<button className={`btn btn-sm ${tab === 'created' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('created')}>Created by Me</button>
|
{isModerator && <button className={`btn btn-sm ${tab === 'created' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('created')}>Created by Me</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -134,7 +136,11 @@ export default function CoursesPage() {
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||||
{courses.map(course => (
|
{courses.map(course => (
|
||||||
<div key={course.id} className="card" style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div key={course.id} className="card" style={{ padding: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{course.thumbnail_path && (
|
||||||
|
<img src={course.thumbnail_path} alt="" style={{ width: '100%', height: 140, objectFit: 'cover' }} />
|
||||||
|
)}
|
||||||
|
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
|
||||||
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
|
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
|
||||||
{course.description && (
|
{course.description && (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 0, lineHeight: 1.5 }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 0, lineHeight: 1.5 }}>
|
||||||
|
|
@ -146,11 +152,17 @@ export default function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
{course.is_enrolled ? (
|
{course.is_enrolled ? (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Continue</button>
|
<>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Start</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||||
|
try { await api.delete(`/courses/${course.id}/enroll`); setCourses(prev => prev.map(c => c.id === course.id ? { ...c, is_enrolled: false, enrolled_count: Math.max(0, (c.enrolled_count || 1) - 1) } : c)) } catch {}
|
||||||
|
}}>Unenroll</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => enroll(course.id)}>Enroll</button>
|
<button className="btn btn-primary btn-sm" onClick={() => enroll(course.id)}>Enroll</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,10 +199,10 @@ export default function CoursesPage() {
|
||||||
{course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} · by {course.creator_name || 'Unknown'}
|
{course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} · by {course.creator_name || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar" style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
<div className="progress-bar" style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
<div className="fill" style={{ width: `${course.progress || 0}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
|
<div className="fill" style={{ width: `${course.progress_pct || 0}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
{course.progress || 0}% complete
|
{course.progress_pct || 0}% complete
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||||
|
|
@ -246,10 +258,10 @@ export default function CoursesPage() {
|
||||||
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
|
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.72rem', padding: '2px 8px', borderRadius: 10, fontWeight: 600,
|
fontSize: '0.72rem', padding: '2px 8px', borderRadius: 10, fontWeight: 600,
|
||||||
background: course.is_published ? '#d1fae5' : '#fef3c7',
|
background: course.status === 'published' ? '#d1fae5' : '#fef3c7',
|
||||||
color: course.is_published ? '#065f46' : '#92400e',
|
color: course.status === 'published' ? '#065f46' : '#92400e',
|
||||||
}}>
|
}}>
|
||||||
{course.is_published ? 'Published' : 'Draft'}
|
{course.status === 'published' ? 'Published' : 'Draft'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{course.description && (
|
{course.description && (
|
||||||
|
|
@ -263,8 +275,8 @@ export default function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${course.id}/edit`)}>Edit</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${course.id}/edit`)}>Edit</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.is_published)}>
|
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.status === 'published')}>
|
||||||
{course.is_published ? 'Unpublish' : 'Publish'}
|
{course.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
{deletingId === course.id ? (
|
{deletingId === course.id ? (
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ function greeting(name) {
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
const [documents, setDocuments] = useState([])
|
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [history, setHistory] = useState([])
|
const [history, setHistory] = useState([])
|
||||||
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||||
|
|
@ -39,12 +38,10 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const promises = [
|
const promises = [
|
||||||
isModerator ? api.get('/documents/') : Promise.resolve({ data: [] }),
|
|
||||||
api.get('/attempts/stats/dashboard'),
|
api.get('/attempts/stats/dashboard'),
|
||||||
api.get('/attempts/history'),
|
api.get('/attempts/history'),
|
||||||
]
|
]
|
||||||
Promise.all(promises).then(([docsRes, statsRes, histRes]) => {
|
Promise.all(promises).then(([statsRes, histRes]) => {
|
||||||
setDocuments(docsRes.data)
|
|
||||||
setStats(statsRes.data)
|
setStats(statsRes.data)
|
||||||
setHistory(histRes.data)
|
setHistory(histRes.data)
|
||||||
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||||
|
|
@ -66,7 +63,6 @@ export default function DashboardPage() {
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
{[
|
{[
|
||||||
...(isModerator ? [{ value: stats.total_documents, label: 'Documents' }] : []),
|
|
||||||
{ value: stats.total_quizzes, label: 'Quizzes' },
|
{ value: stats.total_quizzes, label: 'Quizzes' },
|
||||||
{ value: stats.total_attempts, label: 'Attempts' },
|
{ value: stats.total_attempts, label: 'Attempts' },
|
||||||
{ value: `${stats.average_score}%`, label: 'Avg Score' },
|
{ value: `${stats.average_score}%`, label: 'Avg Score' },
|
||||||
|
|
@ -159,28 +155,8 @@ export default function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isModerator && (
|
{isModerator && (
|
||||||
<div className="card">
|
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
|
||||||
<h2>Documents</h2>
|
|
||||||
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
|
|
||||||
</div>
|
|
||||||
{documents.length === 0 ? (
|
|
||||||
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
|
|
||||||
) : (
|
|
||||||
documents.map(doc => (
|
|
||||||
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
||||||
<div className="section-item">
|
|
||||||
<div>
|
|
||||||
<strong>{doc.original_filename}</strong>
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
|
||||||
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ export default function ForgotPasswordPage() {
|
||||||
if (err.response?.status === 429) {
|
if (err.response?.status === 429) {
|
||||||
setError('Too many reset requests. Please wait before trying again.')
|
setError('Too many reset requests. Please wait before trying again.')
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.detail || 'Something went wrong.')
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') setError(detail)
|
||||||
|
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input.')
|
||||||
|
else setError('Something went wrong.')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
const [resendSent, setResendSent] = useState(false)
|
const [resendSent, setResendSent] = useState(false)
|
||||||
const [resending, setResending] = useState(false)
|
const [resending, setResending] = useState(false)
|
||||||
const [registered, setRegistered] = useState(false)
|
const [registered, setRegistered] = useState(false)
|
||||||
|
const [passwordWarning, setPasswordWarning] = useState('')
|
||||||
|
|
||||||
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
|
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
|
||||||
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
|
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
|
||||||
|
|
@ -202,6 +203,9 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
||||||
|
if (res.data.password_warning) {
|
||||||
|
setPasswordWarning(res.data.password_warning)
|
||||||
|
}
|
||||||
if (res.data.requires_verification) {
|
if (res.data.requires_verification) {
|
||||||
setRegistered(true)
|
setRegistered(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -210,7 +214,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Registration failed')
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') setError(detail)
|
||||||
|
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
|
||||||
|
else setError('Registration failed')
|
||||||
} finally { setLoading(false) }
|
} finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +266,11 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
|
||||||
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
|
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
|
||||||
</p>
|
</p>
|
||||||
|
{passwordWarning && (
|
||||||
|
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 12, fontSize: '0.82rem', color: '#92400e', textAlign: 'left' }}>
|
||||||
|
⚠️ {passwordWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
|
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
|
||||||
Go to Sign In
|
Go to Sign In
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -456,7 +468,7 @@ export default function LandingPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://peds.danvics.com"
|
href="https://scribe.pedshub.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
@ -521,7 +533,7 @@ export default function LandingPage() {
|
||||||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||||
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
||||||
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
||||||
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
<a href="https://scribe.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ export default function LoginPage() {
|
||||||
if (err.response?.status === 403) {
|
if (err.response?.status === 403) {
|
||||||
setUnverified(true)
|
setUnverified(true)
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.detail || 'Login failed')
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') setError(detail)
|
||||||
|
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
|
||||||
|
else setError('Login failed')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,37 @@ import Dialog from '../components/Dialog'
|
||||||
import { useDialog } from '../hooks/useDialog'
|
import { useDialog } from '../hooks/useDialog'
|
||||||
|
|
||||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||||
|
const RichEditor = lazy(() => import('../components/RichEditor'))
|
||||||
|
|
||||||
function QuestionStudyModal({ question, onClose }) {
|
/** Strip HTML tags for plain text display (truncated cards). */
|
||||||
|
function stripHtml(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }) {
|
||||||
const [answered, setAnswered] = useState(null)
|
const [answered, setAnswered] = useState(null)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
onClick={e => e.target === e.currentTarget && onClose()}>
|
onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
|
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
|
||||||
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
|
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{onToggleFavorite && (
|
||||||
|
<button onClick={() => onToggleFavorite(question.id)}
|
||||||
|
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.4rem', padding: 2, lineHeight: 1 }}>
|
||||||
|
{isFavorited ? '⭐' : '☆'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>{question.question_text}</p>
|
<div style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: question.question_text }} />
|
||||||
{question.image_path && (
|
{question.image_path && (
|
||||||
<div style={{ margin: '0 0 14px' }}>
|
<div style={{ margin: '0 0 14px' }}>
|
||||||
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
|
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
|
||||||
|
|
@ -210,15 +226,8 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
||||||
setSaving(true); setError('')
|
setSaving(true); setError('')
|
||||||
try {
|
try {
|
||||||
const payload = { ...form, question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null }
|
const payload = { ...form, question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null }
|
||||||
// Update via quiz edit endpoint (uses source quiz for routing)
|
const res = await api.patch(`/questions/${question.id}`, payload)
|
||||||
const res = await api.patch(`/quizzes/${question.quiz_id}/questions/${question.id}`, payload)
|
onSaved({ ...question, ...res.data,
|
||||||
// Also update category if changed
|
|
||||||
if (form.question_category_id !== (question.question_category_id || '')) {
|
|
||||||
await api.patch(`/questions/${question.id}/category`, null, {
|
|
||||||
params: { category_id: form.question_category_id ? parseInt(form.question_category_id) : '' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onSaved({ ...question, ...res.data, question_category_id: payload.question_category_id,
|
|
||||||
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
|
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
|
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
|
||||||
|
|
@ -283,27 +292,72 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateQuestionModal({ categories, onCreated, onClose }) {
|
function CreateQuestionModal({ categories, onCreated, onClose }) {
|
||||||
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', explanation: '', question_category_id: '' })
|
const LETTERS = ['A','B','C','D','E','F','G','H']
|
||||||
|
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', correctIndex: -1, explanation: '', question_category_id: '', image_path: '' })
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [imageTab, setImageTab] = useState('upload') // 'upload' | 'bank'
|
||||||
|
const [imageBank, setImageBank] = useState([])
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false)
|
||||||
|
|
||||||
const setOption = (i, val) => {
|
const setOption = (i, val) => {
|
||||||
const updated = [...form.options]
|
const updated = [...form.options]
|
||||||
const wasCorrect = form.options[i] === form.correct_answer
|
|
||||||
updated[i] = val
|
updated[i] = val
|
||||||
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
|
setForm(f => ({ ...f, options: updated, correct_answer: f.correctIndex === i ? val : f.correct_answer }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCorrectIndex = (i) => {
|
||||||
|
setForm(f => ({ ...f, correctIndex: i, correct_answer: f.options[i] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOption = (i) => {
|
||||||
|
setForm(f => {
|
||||||
|
const opts = f.options.filter((_, idx) => idx !== i)
|
||||||
|
let ci = f.correctIndex
|
||||||
|
if (ci === i) ci = -1
|
||||||
|
else if (ci > i) ci--
|
||||||
|
return { ...f, options: opts, correctIndex: ci, correct_answer: ci >= 0 ? opts[ci] : '' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadImageBank = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/questions/images')
|
||||||
|
setImageBank(res.data)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingImage(true)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
const res = await api.post('/questions/upload-image', fd)
|
||||||
|
setForm(f => ({ ...f, image_path: res.data.image_path }))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Image upload failed')
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
if (!form.question_text.trim()) return setError('Question text is required')
|
||||||
|
const validOpts = form.options.filter(o => o.trim())
|
||||||
|
if (validOpts.length < 2) return setError('At least 2 options are required')
|
||||||
|
if (form.correctIndex < 0 || !form.correct_answer) return setError('Select the correct answer')
|
||||||
setSaving(true); setError('')
|
setSaving(true); setError('')
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
question_text: form.question_text,
|
question_text: form.question_text,
|
||||||
question_type: form.question_type,
|
question_type: form.question_type,
|
||||||
options: form.options.filter(o => o.trim()),
|
options: validOpts,
|
||||||
correct_answer: form.correct_answer,
|
correct_answer: form.correct_answer,
|
||||||
explanation: form.explanation || null,
|
explanation: form.explanation || null,
|
||||||
question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null,
|
question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null,
|
||||||
|
image_path: form.image_path || null,
|
||||||
}
|
}
|
||||||
const res = await api.post('/questions/create', payload)
|
const res = await api.post('/questions/create', payload)
|
||||||
onCreated(res.data)
|
onCreated(res.data)
|
||||||
|
|
@ -314,42 +368,127 @@ function CreateQuestionModal({ categories, onCreated, onClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 560, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
|
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 28, maxWidth: 720, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>Create Question</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
<h2 style={{ fontSize: '1.15rem', margin: 0 }}>Create Question</h2>
|
||||||
<div className="form-group">
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||||
<label>Question Text</label>
|
|
||||||
<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
|
|
||||||
rows={3} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Category</label>
|
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Question Text</label>
|
||||||
|
<Suspense fallback={<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} rows={4}
|
||||||
|
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
|
||||||
|
<RichEditor value={form.question_text} onChange={(v) => setForm(f => ({ ...f, question_text: v }))} height={180} placeholder="Write the question..." />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Category</label>
|
||||||
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}
|
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }}>
|
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Options</label>
|
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Image (optional)</label>
|
||||||
{form.options.map((opt, i) => (
|
{form.image_path ? (
|
||||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 8 }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', width: 20 }}>{String.fromCharCode(65 + i)}</span>
|
<img src={`/uploads/${form.image_path}`} alt="Question"
|
||||||
<input value={opt} onChange={e => setOption(i, e.target.value)} placeholder={`Option ${String.fromCharCode(65 + i)}`}
|
style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 8, border: '1px solid var(--border)' }} />
|
||||||
style={{ flex: 1, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem' }} />
|
<button onClick={() => setForm(f => ({ ...f, image_path: '' }))}
|
||||||
<input type="radio" name="correct" checked={form.correct_answer === opt && opt !== ''} onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
|
style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
title="Mark as correct" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||||
|
<button className={`btn btn-sm ${imageTab === 'upload' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setImageTab('upload')} style={{ fontSize: '0.78rem' }}>Upload New</button>
|
||||||
|
<button className={`btn btn-sm ${imageTab === 'bank' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => { setImageTab('bank'); loadImageBank() }} style={{ fontSize: '0.78rem' }}>Image Bank</button>
|
||||||
|
</div>
|
||||||
|
{imageTab === 'upload' && (
|
||||||
|
<div>
|
||||||
|
<input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploadingImage}
|
||||||
|
style={{ fontSize: '0.85rem' }} />
|
||||||
|
{uploadingImage && <span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginLeft: 8 }}>Uploading...</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageTab === 'bank' && (
|
||||||
|
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, padding: 4 }}>
|
||||||
|
{imageBank.length === 0 && <div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', gridColumn: '1/-1' }}>No images in bank yet. Upload one first.</div>}
|
||||||
|
{imageBank.map((img, i) => (
|
||||||
|
<div key={i} onClick={() => setForm(f => ({ ...f, image_path: img.image_path }))}
|
||||||
|
style={{ cursor: 'pointer', border: '2px solid var(--border)', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'} onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
||||||
|
<img src={img.url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
|
||||||
|
onError={e => e.target.style.display = 'none'} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Explanation (optional)</label>
|
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>
|
||||||
<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
|
Options — click the letter to mark as correct
|
||||||
rows={2} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{form.options.map((opt, i) => {
|
||||||
|
const isCorrect = form.correctIndex === i
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button onClick={() => setCorrectIndex(i)} title={isCorrect ? 'Correct answer' : 'Click to mark as correct'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32,
|
||||||
|
borderRadius: '50%', flexShrink: 0, fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer',
|
||||||
|
border: isCorrect ? '2px solid var(--correct-fg)' : '2px solid var(--border)',
|
||||||
|
background: isCorrect ? 'var(--correct-fg)' : 'transparent',
|
||||||
|
color: isCorrect ? 'white' : 'var(--text-muted)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
{LETTERS[i]}
|
||||||
|
</button>
|
||||||
|
<input value={opt} onChange={e => setOption(i, e.target.value)}
|
||||||
|
placeholder={`Option ${LETTERS[i]}`}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '10px 14px',
|
||||||
|
border: `1.5px solid ${isCorrect ? 'var(--correct-bd)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 8, background: isCorrect ? 'var(--correct-bg)' : 'var(--input-bg)',
|
||||||
|
color: 'var(--text)', fontSize: '0.9rem', fontFamily: 'inherit',
|
||||||
|
}} />
|
||||||
|
{form.options.length > 2 && (
|
||||||
|
<button onClick={() => removeOption(i)} title="Remove option"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', padding: '4px 6px' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{form.options.length < 8 && (
|
||||||
|
<button className="btn btn-sm btn-secondary" style={{ marginTop: 8 }}
|
||||||
|
onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button className="btn btn-primary" onClick={save} disabled={saving || !form.question_text.trim() || !form.correct_answer}>
|
<div className="form-group">
|
||||||
|
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Explanation (optional)</label>
|
||||||
|
<Suspense fallback={<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} rows={3}
|
||||||
|
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
|
||||||
|
<RichEditor value={form.explanation} onChange={(v) => setForm(f => ({ ...f, explanation: v }))} height={150} placeholder="Explain why this is the correct answer..." />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||||
{saving ? 'Creating...' : 'Create Question'}
|
{saving ? 'Creating...' : 'Create Question'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
|
@ -370,6 +509,7 @@ export default function QuestionBankPage() {
|
||||||
const [filterCatIds, setFilterCatIds] = useState([])
|
const [filterCatIds, setFilterCatIds] = useState([])
|
||||||
const [showUncategorized, setShowUncategorized] = useState(false)
|
const [showUncategorized, setShowUncategorized] = useState(false)
|
||||||
const [showFavorites, setShowFavorites] = useState(false)
|
const [showFavorites, setShowFavorites] = useState(false)
|
||||||
|
const [showMyQuestions, setShowMyQuestions] = useState(false)
|
||||||
const [favorites, setFavorites] = useState([])
|
const [favorites, setFavorites] = useState([])
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
const [studyQuestion, setStudyQuestion] = useState(null)
|
const [studyQuestion, setStudyQuestion] = useState(null)
|
||||||
|
|
@ -388,6 +528,10 @@ export default function QuestionBankPage() {
|
||||||
const [classifyStatus, setClassifyStatus] = useState(null)
|
const [classifyStatus, setClassifyStatus] = useState(null)
|
||||||
const [classifySteps, setClassifySteps] = useState([])
|
const [classifySteps, setClassifySteps] = useState([])
|
||||||
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
|
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
|
||||||
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
const [importFile, setImportFile] = useState(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importResult, setImportResult] = useState(null)
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
const classifyPollRef = useRef(null)
|
const classifyPollRef = useRef(null)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -412,6 +556,7 @@ export default function QuestionBankPage() {
|
||||||
if (catIds.length > 0) params.category_ids = catIds.join(',')
|
if (catIds.length > 0) params.category_ids = catIds.join(',')
|
||||||
if (uncatOnly) params.uncategorized = true
|
if (uncatOnly) params.uncategorized = true
|
||||||
if (favOnly) params.favorites_only = true
|
if (favOnly) params.favorites_only = true
|
||||||
|
if (showMyQuestions) params.my_questions = true
|
||||||
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
|
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
|
||||||
const res = await api.get('/questions/bank', { params })
|
const res = await api.get('/questions/bank', { params })
|
||||||
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
|
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
|
||||||
|
|
@ -426,7 +571,7 @@ export default function QuestionBankPage() {
|
||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
|
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
|
||||||
return () => clearTimeout(debounceRef.current)
|
return () => clearTimeout(debounceRef.current)
|
||||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, searchMode, pageSize, tagIdsKey])
|
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey])
|
||||||
|
|
||||||
const toggleTag = (tagId) => {
|
const toggleTag = (tagId) => {
|
||||||
setSelectedTagIds(prev => {
|
setSelectedTagIds(prev => {
|
||||||
|
|
@ -535,6 +680,26 @@ export default function QuestionBankPage() {
|
||||||
setMoveToCatId('')
|
setMoveToCatId('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!importFile) return
|
||||||
|
setImporting(true)
|
||||||
|
setImportResult(null)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', importFile)
|
||||||
|
const res = await api.post('/questions/import/upload', fd)
|
||||||
|
setImportResult(res.data)
|
||||||
|
if (res.data.imported > 0) {
|
||||||
|
loadQuestions()
|
||||||
|
api.get('/question-categories/').then(r => setCategories(r.data))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setImportResult({ error: err.response?.data?.detail || 'Import failed' })
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFavorite = async (questionId) => {
|
const toggleFavorite = async (questionId) => {
|
||||||
const isFavorited = favorites.includes(questionId)
|
const isFavorited = favorites.includes(questionId)
|
||||||
try {
|
try {
|
||||||
|
|
@ -579,13 +744,55 @@ export default function QuestionBankPage() {
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />}
|
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)}
|
||||||
|
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
|
||||||
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
|
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
|
||||||
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
|
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
|
||||||
onClose={() => setEditQuestion(null)} />}
|
onClose={() => setEditQuestion(null)} />}
|
||||||
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
|
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
|
||||||
{showCreateQuestion && <CreateQuestionModal categories={categories} onCreated={(q) => { setQuestions(prev => [q, ...prev]); setTotal(t => t + 1) }} onClose={() => setShowCreateQuestion(false)} />}
|
{showCreateQuestion && <CreateQuestionModal categories={categories} onCreated={(q) => { setQuestions(prev => [q, ...prev]); setTotal(t => t + 1) }} onClose={() => setShowCreateQuestion(false)} />}
|
||||||
|
|
||||||
|
{/* Import CSV/Excel Modal */}
|
||||||
|
{showImport && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||||
|
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 28, maxWidth: 520, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Import Questions</h2>
|
||||||
|
<button onClick={() => setShowImport(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||||
|
Upload a CSV or Excel (.xlsx) file with columns: <strong>question_text</strong>, <strong>option_a</strong> through <strong>option_e</strong>, <strong>correct_answer</strong> (A/B/C/D/E), <strong>explanation</strong>, <strong>category</strong>.
|
||||||
|
</p>
|
||||||
|
<a href="/api/questions/import/sample" download style={{ fontSize: '0.85rem', color: 'var(--primary)', marginBottom: 14, display: 'inline-block' }}>
|
||||||
|
Download sample CSV template
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 12, marginBottom: 14 }}>
|
||||||
|
<input type="file" accept=".csv,.xlsx,.xls" onChange={e => { setImportFile(e.target.files[0]); setImportResult(null) }}
|
||||||
|
style={{ fontSize: '0.88rem' }} />
|
||||||
|
</div>
|
||||||
|
{importResult && !importResult.error && (
|
||||||
|
<div className="alert" style={{ background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginBottom: 12, fontSize: '0.85rem' }}>
|
||||||
|
Imported <strong>{importResult.imported}</strong> of {importResult.total_rows} questions.
|
||||||
|
{importResult.errors?.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||||
|
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importResult?.error && (
|
||||||
|
<div className="alert alert-error" style={{ marginBottom: 12, fontSize: '0.85rem' }}>{importResult.error}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-primary" onClick={handleImport} disabled={!importFile || importing}>
|
||||||
|
{importing ? 'Importing...' : 'Upload & Import'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowImport(false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
|
@ -600,6 +807,7 @@ export default function QuestionBankPage() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
|
||||||
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
|
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -622,8 +830,10 @@ export default function QuestionBankPage() {
|
||||||
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
|
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
|
||||||
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}>⭐ Favorites ({favorites.length})</button>
|
onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}>⭐ Favorites ({favorites.length})</button>
|
||||||
|
<button className={`btn btn-sm ${showMyQuestions ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => { setShowMyQuestions(v => !v); setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>My Questions</button>
|
||||||
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false) }}>Uncategorized</button>
|
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false); setShowMyQuestions(false) }}>Uncategorized</button>
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const isActive = filterCatIds.includes(cat.id)
|
const isActive = filterCatIds.includes(cat.id)
|
||||||
return (
|
return (
|
||||||
|
|
@ -772,7 +982,7 @@ export default function QuestionBankPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 6 : 0 }}>
|
<p style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 6 : 0 }}>
|
||||||
{q.question_text.slice(0, 200)}{q.question_text.length > 200 ? '…' : ''}
|
{stripHtml(q.question_text).slice(0, 200)}{stripHtml(q.question_text).length > 200 ? '...' : ''}
|
||||||
</p>
|
</p>
|
||||||
{q.options && (
|
{q.options && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
|
@ -804,7 +1014,26 @@ export default function QuestionBankPage() {
|
||||||
{favorites.includes(q.id) ? '⭐' : '☆'}
|
{favorites.includes(q.id) ? '⭐' : '☆'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
|
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
|
||||||
|
{(q.user_id === user?.id || isModerator) && (
|
||||||
|
<button className="btn btn-sm btn-secondary" style={{ fontSize: '0.72rem' }}
|
||||||
|
onClick={async () => {
|
||||||
|
const newVal = q.is_shared ? 0 : 1
|
||||||
|
try {
|
||||||
|
await api.patch(`/questions/${q.id}/share?shared=${newVal}`)
|
||||||
|
setQuestions(prev => prev.map(x => x.id === q.id ? { ...x, is_shared: newVal } : x))
|
||||||
|
} catch {}
|
||||||
|
}}>{q.is_shared ? 'Unshare' : 'Share'}</button>
|
||||||
|
)}
|
||||||
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
|
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
|
||||||
|
{isModerator && <button className="btn btn-sm btn-secondary" style={{ color: 'var(--wrong-fg)' }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!await openAlert(`Delete "${stripHtml(q.question_text).slice(0, 80)}..."? This removes it from all quizzes.`, { title: 'Delete Question', confirmLabel: 'Delete', cancelLabel: 'Cancel' })) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/questions/${q.id}`)
|
||||||
|
setQuestions(prev => prev.filter(x => x.id !== q.id))
|
||||||
|
setTotal(t => t - 1)
|
||||||
|
} catch (err) { await openAlert(err.response?.data?.detail || 'Delete failed', { title: 'Error' }) }
|
||||||
|
}}>Delete</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
|
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
|
|
@ -73,6 +73,29 @@ function TimerDisplay({ seconds, total }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CourseQuizStart({ quiz, onStart }) {
|
||||||
|
const mode = quiz.mode === 'timed' ? 'exam' : 'study'
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||||
|
<div className="card" style={{ textAlign: 'center' }}>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
|
||||||
|
{quiz.questions_per_attempt || quiz.questions_count} questions
|
||||||
|
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '0.88rem', marginBottom: 20 }}>
|
||||||
|
{mode === 'exam'
|
||||||
|
? 'This is a timed exam — answers are hidden until you submit.'
|
||||||
|
: 'Study mode — answers and explanations shown as you go.'}
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => onStart(mode, '', quiz.time_limit_minutes || null)}>
|
||||||
|
Begin Quiz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ModeSelectScreen({ quiz, voices, onStart }) {
|
function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||||||
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
|
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
|
||||||
|
|
@ -134,6 +157,8 @@ const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||||
export default function QuizPage() {
|
export default function QuizPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const returnTo = searchParams.get('return_to')
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
const [quiz, setQuiz] = useState(null)
|
const [quiz, setQuiz] = useState(null)
|
||||||
|
|
@ -170,10 +195,11 @@ export default function QuizPage() {
|
||||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||||
const savedAnswers = saved.answers || {}
|
const savedAnswers = saved.answers || {}
|
||||||
|
|
||||||
|
const aid = saved.attempt_id || saved.attemptId || ''
|
||||||
// For study mode, load quiz with correct answers BEFORE showing
|
// For study mode, load quiz with correct answers BEFORE showing
|
||||||
if (mode === 'study') {
|
if (mode === 'study') {
|
||||||
try {
|
try {
|
||||||
const quizRes = await api.get(`/quizzes/${id}?study=true`)
|
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
|
||||||
setQuiz(quizRes.data)
|
setQuiz(quizRes.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load quiz:', err)
|
console.error('Failed to load quiz:', err)
|
||||||
|
|
@ -183,7 +209,7 @@ export default function QuizPage() {
|
||||||
} else {
|
} else {
|
||||||
// For exam mode, ensure quiz data is loaded
|
// For exam mode, ensure quiz data is loaded
|
||||||
try {
|
try {
|
||||||
const quizRes = await api.get(`/quizzes/${id}`)
|
const quizRes = await api.get(`/quizzes/${id}${aid ? `?attempt_id=${aid}` : ''}`)
|
||||||
setQuiz(quizRes.data)
|
setQuiz(quizRes.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load quiz:', err)
|
console.error('Failed to load quiz:', err)
|
||||||
|
|
@ -263,16 +289,17 @@ export default function QuizPage() {
|
||||||
// (prevents mobile race condition where quiz renders without correct_answer)
|
// (prevents mobile race condition where quiz renders without correct_answer)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let quizData = quiz
|
// Start attempt first (may select random question subset)
|
||||||
if (mode === 'study') {
|
|
||||||
// Need answers/explanations — fetch the full study version
|
|
||||||
const quizRes = await api.get(`/quizzes/${id}?study=true`)
|
|
||||||
quizData = quizRes.data
|
|
||||||
setQuiz(quizData)
|
|
||||||
}
|
|
||||||
// Exam mode reuses the already-loaded quiz (no answers needed)
|
|
||||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||||
setAttemptId(attemptRes.data.id)
|
setAttemptId(attemptRes.data.id)
|
||||||
|
const aid = attemptRes.data.id
|
||||||
|
|
||||||
|
// Fetch quiz with attempt_id for question pool filtering
|
||||||
|
let quizData = quiz
|
||||||
|
const studyParam = (mode === 'study') ? '&study=true' : ''
|
||||||
|
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
|
||||||
|
quizData = quizRes.data
|
||||||
|
setQuiz(quizData)
|
||||||
const mins = timerMinutes || quizData.time_limit_minutes
|
const mins = timerMinutes || quizData.time_limit_minutes
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
setStartedAt(now)
|
setStartedAt(now)
|
||||||
|
|
@ -341,7 +368,11 @@ const timerStarted = timeLeft !== null
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
if (returnTo) {
|
||||||
|
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
|
||||||
|
} else {
|
||||||
|
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
|
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
|
||||||
} finally { setSubmitting(false) }
|
} finally { setSubmitting(false) }
|
||||||
|
|
@ -362,6 +393,8 @@ const timerStarted = timeLeft !== null
|
||||||
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz…</div>
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz…</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : returnTo ? (
|
||||||
|
<CourseQuizStart quiz={quiz} onStart={startQuiz} />
|
||||||
) : (
|
) : (
|
||||||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -415,11 +448,13 @@ const timerStarted = timeLeft !== null
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>⏸</div>
|
<div style={{ fontSize: '2rem', marginBottom: 12 }}>⏸</div>
|
||||||
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
|
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
|
||||||
Your progress is saved — resume from where you left off.
|
Your progress is saved — you can resume from where you left off.
|
||||||
{timeLeft !== null && (
|
{timeLeft !== null && (
|
||||||
<><br/><br/>
|
<><br/><br/>
|
||||||
<strong style={{ color: '#f59e0b' }}>⚠️ Timer continues:</strong> This quiz is timed.
|
<strong style={{ color: '#f59e0b' }}>⚠️ Timer is still running.</strong> If time expires before you return, the quiz will be auto-submitted with your current answers.
|
||||||
If you leave, the timer keeps counting and will auto-submit when it expires.
|
{returnTo && quiz?.max_attempts && (
|
||||||
|
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -460,7 +495,7 @@ const timerStarted = timeLeft !== null
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget('/')} title="Save progress and exit">
|
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
|
||||||
⏸ Suspend
|
⏸ Suspend
|
||||||
</button>
|
</button>
|
||||||
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export default function RegisterPage() {
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
|
const [passwordWarning, setPasswordWarning] = useState('')
|
||||||
const [turnstileToken, setTurnstileToken] = useState('')
|
const [turnstileToken, setTurnstileToken] = useState('')
|
||||||
const { loginWithToken } = useAuth()
|
const { loginWithToken } = useAuth()
|
||||||
|
|
||||||
|
|
@ -45,6 +46,9 @@ export default function RegisterPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
||||||
|
if (res.data.password_warning) {
|
||||||
|
setPasswordWarning(res.data.password_warning)
|
||||||
|
}
|
||||||
if (res.data.requires_verification) {
|
if (res.data.requires_verification) {
|
||||||
setDone(true)
|
setDone(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -53,7 +57,12 @@ export default function RegisterPage() {
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Registration failed')
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') setError(detail)
|
||||||
|
else if (Array.isArray(detail)) {
|
||||||
|
const hasEmail = detail.some(e => e.loc?.includes('email'))
|
||||||
|
setError(hasEmail ? 'Invalid email address.' : 'Please check your input and try again.')
|
||||||
|
} else setError('Registration failed')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +80,11 @@ export default function RegisterPage() {
|
||||||
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
||||||
Click the link in the email to activate your account, then you can log in.
|
Click the link in the email to activate your account, then you can log in.
|
||||||
</p>
|
</p>
|
||||||
|
{passwordWarning && (
|
||||||
|
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 16, fontSize: '0.85rem', color: '#92400e', textAlign: 'left' }}>
|
||||||
|
⚠️ {passwordWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
|
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
|
||||||
<div className="auth-link" style={{ marginTop: 16 }}>
|
<div className="auth-link" style={{ marginTop: 16 }}>
|
||||||
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>
|
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,12 @@ export default function ResetPasswordPage() {
|
||||||
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/reset-password', { token, new_password: password })
|
const res = await api.post('/auth/reset-password', { token, new_password: password })
|
||||||
|
if (res.data.password_warning) {
|
||||||
|
setError(res.data.password_warning)
|
||||||
|
}
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
setTimeout(() => navigate('/login'), 2500)
|
setTimeout(() => navigate('/login'), 3500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
|
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const returnTo = searchParams.get('return_to')
|
||||||
const [result, setResult] = useState(location.state?.result || null)
|
const [result, setResult] = useState(location.state?.result || null)
|
||||||
const [loading, setLoading] = useState(!result)
|
const [loading, setLoading] = useState(!result)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
const isCourseQuiz = result?.course_id != null
|
||||||
|
const reviewAllowed = result?.allow_review !== false
|
||||||
|
|
||||||
const deleteAttempt = async () => {
|
const deleteAttempt = async () => {
|
||||||
if (!confirmDelete) { setConfirmDelete(true); return }
|
if (!confirmDelete) { setConfirmDelete(true); return }
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attempts/${result.id}`)
|
await api.delete(`/attempts/${result.id}`)
|
||||||
navigate('/quizzes')
|
navigate(returnTo || '/quizzes')
|
||||||
} catch {
|
} catch {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
setConfirmDelete(false)
|
setConfirmDelete(false)
|
||||||
|
|
@ -27,7 +32,7 @@ export default function ResultsPage() {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
api.get(`/attempts/${id}`)
|
api.get(`/attempts/${id}`)
|
||||||
.then(res => setResult(res.data))
|
.then(res => setResult(res.data))
|
||||||
.catch(() => navigate('/quizzes'))
|
.catch(() => navigate(returnTo || '/quizzes'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
@ -37,7 +42,7 @@ export default function ResultsPage() {
|
||||||
|
|
||||||
const pct = result.percentage
|
const pct = result.percentage
|
||||||
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
|
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
|
||||||
const correct = result.answers?.filter(a => a.is_correct).length ?? result.score
|
const correct = result.answers?.length > 0 ? result.answers.filter(a => a.is_correct).length : result.score
|
||||||
const total = result.total_questions
|
const total = result.total_questions
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -71,131 +76,148 @@ export default function ResultsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
{isCourseQuiz ? (
|
||||||
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
|
||||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
|
||||||
{confirmDelete ? (
|
|
||||||
<>
|
<>
|
||||||
<button
|
{returnTo && (
|
||||||
onClick={deleteAttempt}
|
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
|
||||||
disabled={deleting}
|
)}
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting…' : 'Confirm Delete'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
onClick={deleteAttempt}
|
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||||
className="btn btn-secondary"
|
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
||||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||||
>
|
{confirmDelete ? (
|
||||||
Delete Attempt
|
<>
|
||||||
</button>
|
<button
|
||||||
|
onClick={deleteAttempt}
|
||||||
|
disabled={deleting}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Confirm Delete'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={deleteAttempt}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||||
|
>
|
||||||
|
Delete Attempt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary row */}
|
{/* Question review — only if allowed */}
|
||||||
{result.answers && result.answers.length > 0 && (
|
{reviewAllowed && result.answers && result.answers.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
<>
|
||||||
{[
|
{/* Summary row */}
|
||||||
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||||
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
|
{[
|
||||||
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
|
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
||||||
].map(s => (
|
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
|
||||||
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
|
].map(s => (
|
||||||
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
|
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
||||||
</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
|
||||||
))}
|
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
|
||||||
|
|
||||||
{(!result.answers || result.answers.length === 0) ? (
|
|
||||||
<div className="card"><div className="empty-state">No answers recorded.</div></div>
|
|
||||||
) : result.answers.map((ans, idx) => {
|
|
||||||
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
|
||||||
return (
|
|
||||||
<div className={`review-card ${cardClass}`} key={idx}>
|
|
||||||
{/* Question header */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
||||||
Question {idx + 1}
|
|
||||||
</span>
|
|
||||||
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
|
|
||||||
{ans.question_text}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
))}
|
||||||
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
|
</div>
|
||||||
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
|
|
||||||
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options with letter badges */}
|
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
||||||
{ans.options && ans.options.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
|
||||||
{ans.options.map((opt, i) => {
|
|
||||||
const isCorrect = opt === ans.correct_answer
|
|
||||||
const isUser = opt === ans.user_answer
|
|
||||||
const isWrong = isUser && !isCorrect
|
|
||||||
const letter = String.fromCharCode(65 + i)
|
|
||||||
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
|
|
||||||
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
|
|
||||||
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
|
||||||
fontSize: '0.73rem', fontWeight: 700,
|
|
||||||
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
|
|
||||||
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
|
|
||||||
}}>{letter}</span>
|
|
||||||
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
|
|
||||||
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Correct answer</span>}
|
|
||||||
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Your answer</span>}
|
|
||||||
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✗ Your answer</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fill-blank */}
|
{result.answers.map((ans, idx) => {
|
||||||
{(!ans.options || ans.options.length === 0) && (
|
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
||||||
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
|
return (
|
||||||
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
|
<div className={`review-card ${cardClass}`} key={idx}>
|
||||||
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
|
{/* Question header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
Question {idx + 1}
|
||||||
|
</span>
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
|
||||||
|
{ans.question_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
|
||||||
|
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
|
||||||
|
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!ans.is_correct && (
|
|
||||||
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
|
{/* Options with letter badges */}
|
||||||
<strong>Correct answer:</strong> {ans.correct_answer}
|
{ans.options && ans.options.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||||
|
{ans.options.map((opt, i) => {
|
||||||
|
const isCorrect = opt === ans.correct_answer
|
||||||
|
const isUser = opt === ans.user_answer
|
||||||
|
const isWrong = isUser && !isCorrect
|
||||||
|
const letter = String.fromCharCode(65 + i)
|
||||||
|
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
|
||||||
|
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
|
||||||
|
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
||||||
|
fontSize: '0.73rem', fontWeight: 700,
|
||||||
|
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
|
||||||
|
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
|
||||||
|
}}>{letter}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
|
||||||
|
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Correct answer</span>}
|
||||||
|
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Your answer</span>}
|
||||||
|
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✗ Your answer</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fill-blank */}
|
||||||
|
{(!ans.options || ans.options.length === 0) && (
|
||||||
|
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
|
||||||
|
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
|
||||||
|
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
|
||||||
|
</div>
|
||||||
|
{!ans.is_correct && (
|
||||||
|
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
|
||||||
|
<strong>Correct answer:</strong> {ans.correct_answer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explanation */}
|
||||||
|
{ans.explanation && (
|
||||||
|
<div className="explanation">
|
||||||
|
<strong>Explanation</strong>
|
||||||
|
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Explanation */}
|
{!reviewAllowed && (
|
||||||
{ans.explanation && (
|
<div className="card" style={{ textAlign: 'center' }}>
|
||||||
<div className="explanation">
|
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
|
||||||
<strong>Explanation</strong>
|
</div>
|
||||||
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,43 @@ function AdminSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocumentsSection() {
|
||||||
|
const [documents, setDocuments] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/documents/').then(res => setDocuments(res.data)).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Documents">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: 0 }}>
|
||||||
|
Your uploaded PDFs for quiz and flashcard generation.
|
||||||
|
</p>
|
||||||
|
<Link to="/upload" className="btn btn-primary btn-sm">Upload PDF</Link>
|
||||||
|
</div>
|
||||||
|
{loading && <div className="loading"><div className="spinner" /></div>}
|
||||||
|
{!loading && documents.length === 0 && (
|
||||||
|
<div className="empty-state" style={{ padding: '16px 0' }}><p>No documents yet.</p></div>
|
||||||
|
)}
|
||||||
|
{documents.map(doc => (
|
||||||
|
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div className="section-item">
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<strong style={{ fontSize: '0.9rem' }}>{doc.original_filename}</strong>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} · {new Date(doc.uploaded_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
|
|
@ -240,6 +277,7 @@ export default function SettingsPage() {
|
||||||
<ProfileSection user={user} />
|
<ProfileSection user={user} />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
{isModerator && <NextcloudSection />}
|
{isModerator && <NextcloudSection />}
|
||||||
|
{isModerator && <DocumentsSection />}
|
||||||
{(isAdmin || isModerator) && <AdminSection />}
|
{(isAdmin || isModerator) && <AdminSection />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,6 @@ export default function UploadPage() {
|
||||||
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
|
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
|
||||||
Upload & Process
|
Upload & Process
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue