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
|
||||
*.swp
|
||||
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
|
||||
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL 16 (pgvector) + Redis + Celery
|
||||
- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Nginx
|
||||
- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Milkdown (markdown WYSIWYG) + Nginx
|
||||
- **AI**: LiteLLM proxy routes to Claude/GPT/Gemini/Bedrock. `_proxy_model()` in ai_service.py adds `openai/` prefix for the proxy.
|
||||
- **Vectors**: ChromaDB for document page chunks (RAG), pgvector for question embeddings (semantic search)
|
||||
- **Config**: Backend reads `.env` via pydantic-settings. Frontend uses runtime `window.__APP_CONFIG__` injected by `docker-entrypoint.sh` (not Vite build-time env).
|
||||
|
|
@ -26,6 +26,7 @@ Browser → Nginx (frontend) → FastAPI (4 uvicorn workers)
|
|||
├── ChromaDB (document page chunks for extraction context)
|
||||
├── Redis (Celery broker, rate limits, settings, job progress, session locks)
|
||||
└── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration)
|
||||
└── db-backup (daily PostgreSQL dumps, 14 daily / 4 weekly / 6 monthly retention, ./backups/)
|
||||
|
||||
## Course/LMS system
|
||||
Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons.
|
||||
|
|
@ -36,8 +37,11 @@ Any user can create courses (not just moderators). Course structure: Course →
|
|||
- **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate`
|
||||
- **Status**: draft (creator-only) → published (visible to all) → archived
|
||||
- **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder)
|
||||
- **Question bank integration**: quiz lessons select questions from bank by category/tags, creates a quiz via `POST /questions/from-bank`
|
||||
- **Any user can create quizzes** from the question bank (not just moderators). User quizzes are private by default (`is_published=0`, `is_shared=0`)
|
||||
- **Course quizzes**: fully decoupled from main quiz system. `POST /courses/{id}/quiz` copies questions and creates an independent quiz with `course_id` set. Hidden from main quizzes page, search, dashboard stats, and attempt history. Creator sets mode (timed/study), time limit, max attempts, questions per attempt (random pool), and `allow_review` (whether students can review answers). Results page is course-aware — shows "Back to Course" instead of retake/all quizzes/delete. Users see attempt history + review links on the course page only.
|
||||
- **User roles**: `admin`, `moderator`, `user`. Only moderators and admins can create courses. Any user can create quizzes from the question bank.
|
||||
- **Enrollee analytics**: `GET /courses/{id}/enrollees` returns progress + quiz scores. `GET /courses/{id}/enrollees/export` exports CSV.
|
||||
- **Question ownership**: questions have `user_id` and `is_shared`. Users see shared + own questions. `PATCH /questions/{id}/share` toggles visibility.
|
||||
- **Rich editor**: Milkdown (ProseMirror-based markdown WYSIWYG) for lesson content. Supports GFM tables, code blocks, LaTeX math (`$formula$`). No JSX parsing issues with `<` or `{`.
|
||||
```
|
||||
|
||||
## Key directories
|
||||
|
|
@ -74,6 +78,7 @@ frontend/src/
|
|||
components/
|
||||
Navbar.jsx — Auth-aware nav with jobs badge
|
||||
TeachChat.jsx — AI tutor drawer (lazy loaded, markdown/GFM tables)
|
||||
RichEditor.jsx — Milkdown markdown WYSIWYG editor with toolbar, GFM tables, math, history
|
||||
```
|
||||
|
||||
## Database tables (key ones)
|
||||
|
|
@ -82,8 +87,8 @@ frontend/src/
|
|||
| users | Accounts with role (admin/moderator/user) | — |
|
||||
| pdf_documents | Uploaded PDFs | user_id → users |
|
||||
| sections | Page ranges within a document | document_id → pdf_documents |
|
||||
| quizzes | Quiz metadata | section_id → sections (nullable), user_id → users |
|
||||
| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable) |
|
||||
| quizzes | Quiz metadata (course_id set = course-only, allow_review controls student access) | section_id → sections (nullable), user_id → users, course_id → courses (nullable) |
|
||||
| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable), user_id → users (nullable) |
|
||||
| quiz_question_links | Quiz ↔ Question many-to-many | quiz_id, question_id |
|
||||
| flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users |
|
||||
| flashcards | Individual cards (front/back) | deck_id → flashcard_decks |
|
||||
|
|
@ -112,3 +117,9 @@ frontend/src/
|
|||
- Don't use `[someValue === null]` as a useEffect dependency — it evaluates to a constant boolean
|
||||
- Don't `docker compose restart` expecting code changes to apply — must rebuild
|
||||
- Don't use `window.confirm()` — user hates browser popups, use inline confirmation or the Dialog component
|
||||
- Don't use MDXEditor — it's an MDX parser that chokes on `<` and `{` in medical content. Milkdown (CommonMark) is used instead.
|
||||
- Don't create quizzes via `POST /questions/from-bank` for courses — use `POST /courses/{id}/quiz` which copies questions and hides the quiz from the main page.
|
||||
- Don't show course quiz data on the main quizzes page, dashboard stats, or attempt history — course quizzes are fully decoupled. Filter with `Quiz.course_id.is_(None)`.
|
||||
- Don't show "from pool of N" to users on course quiz display — just show the number of questions per attempt.
|
||||
- Don't allow users to delete course quiz attempts — the backend returns 403.
|
||||
- Don't put documents listing on the dashboard — it's in Settings page under Nextcloud.
|
||||
|
|
|
|||
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
|
||||
REFERENCES question_categories(id) ON DELETE SET NULL
|
||||
"""))
|
||||
# Course quiz flag
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quizzes
|
||||
ADD COLUMN IF NOT EXISTS course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quizzes
|
||||
ADD COLUMN IF NOT EXISTS max_attempts INTEGER
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quizzes
|
||||
ADD COLUMN IF NOT EXISTS questions_per_attempt INTEGER
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quiz_attempts
|
||||
ADD COLUMN IF NOT EXISTS selected_question_ids JSON
|
||||
"""))
|
||||
|
||||
# Question ownership
|
||||
conn.execute(text("""
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 1
|
||||
"""))
|
||||
|
||||
# Fix email collation to avoid en_US.utf8 B-tree index corruption
|
||||
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
|
||||
|
||||
|
|
@ -321,6 +350,7 @@ def setup_pgvector():
|
|||
"""))
|
||||
# Quiz sharing
|
||||
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1"))
|
||||
# ── Course / LMS tables ──────────────────────────────────────
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
|
@ -16,6 +16,7 @@ class QuizAttempt(Base):
|
|||
total_questions = Column(Integer, default=0)
|
||||
started_at = Column(DateTime, default=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses
|
||||
|
||||
quiz = relationship("Quiz", back_populates="attempts")
|
||||
user = relationship("User", back_populates="attempts")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class Question(Base):
|
|||
explanation = Column(Text, nullable=True)
|
||||
page_reference = Column(Integer, nullable=True)
|
||||
image_path = Column(String, nullable=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
|
||||
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
|
||||
|
||||
question_category = relationship("QuestionCategory", back_populates="questions",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class Quiz(Base):
|
|||
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
|
||||
is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only)
|
||||
is_shared = Column(Integer, default=0) # 0=private, 1=shared (visible to other users)
|
||||
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=True) # set = course-only quiz, hidden from main page
|
||||
max_attempts = Column(Integer, nullable=True) # null = unlimited
|
||||
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
|
||||
allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review
|
||||
|
||||
section = relationship("Section", back_populates="quizzes")
|
||||
user = relationship("User", back_populates="quizzes")
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@ def start_attempt(
|
|||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
# Enforce max_attempts
|
||||
if quiz.max_attempts:
|
||||
completed_count = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.quiz_id == quiz_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).count()
|
||||
if completed_count >= quiz.max_attempts:
|
||||
raise HTTPException(status_code=403, detail="Maximum attempts reached for this quiz")
|
||||
|
||||
# Reuse the most recent incomplete attempt unless fresh=true
|
||||
if not fresh:
|
||||
existing = db.query(QuizAttempt).filter(
|
||||
|
|
@ -54,10 +64,20 @@ def start_attempt(
|
|||
completed_at=None,
|
||||
)
|
||||
|
||||
# Question pool: randomly select N questions if questions_per_attempt is set
|
||||
import random
|
||||
selected_ids = None
|
||||
total_q = quiz.questions_count
|
||||
if quiz.questions_per_attempt and quiz.questions_per_attempt < quiz.questions_count:
|
||||
all_q_ids = [q.id for q in quiz.questions]
|
||||
selected_ids = sorted(random.sample(all_q_ids, quiz.questions_per_attempt))
|
||||
total_q = quiz.questions_per_attempt
|
||||
|
||||
attempt = QuizAttempt(
|
||||
quiz_id=quiz_id,
|
||||
user_id=current_user.id,
|
||||
total_questions=quiz.questions_count,
|
||||
total_questions=total_q,
|
||||
selected_question_ids=selected_ids,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.commit()
|
||||
|
|
@ -110,21 +130,27 @@ def submit_attempt(
|
|||
is_correct=is_correct,
|
||||
))
|
||||
|
||||
# Check if review is allowed (course quizzes may disallow)
|
||||
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
|
||||
is_course_quiz = quiz and quiz.course_id is not None
|
||||
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
|
||||
|
||||
# Build full review — include ALL questions, unanswered marked as incorrect
|
||||
answer_details = []
|
||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||
user_answer = submitted.get(q.id, "")
|
||||
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=user_answer,
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=is_correct,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
if review_allowed:
|
||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||
user_answer = submitted.get(q.id, "")
|
||||
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=user_answer,
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=is_correct,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
|
||||
attempt.score = score
|
||||
attempt.completed_at = datetime.utcnow()
|
||||
|
|
@ -157,6 +183,8 @@ def submit_attempt(
|
|||
started_at=attempt.started_at,
|
||||
completed_at=attempt.completed_at,
|
||||
answers=answer_details,
|
||||
course_id=quiz.course_id if quiz else None,
|
||||
allow_review=review_allowed,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -338,7 +366,7 @@ def delete_attempt(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete own attempt + answers + reminders for that quiz."""
|
||||
"""Delete own attempt + answers + reminders for that quiz. Cannot delete course quiz attempts."""
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
|
|
@ -346,6 +374,11 @@ def delete_attempt(
|
|||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
||||
|
||||
# Block deletion of course quiz attempts
|
||||
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
|
||||
if quiz and quiz.course_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts")
|
||||
|
||||
quiz_id = attempt.quiz_id
|
||||
# Delete all attempts for this quiz by this user (wipe history)
|
||||
from app.models.reminder import ReminderSchedule
|
||||
|
|
@ -387,11 +420,18 @@ def get_in_progress_attempts(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all incomplete (not yet submitted) attempts for this user."""
|
||||
incomplete = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.is_(None),
|
||||
).order_by(QuizAttempt.started_at.desc()).all()
|
||||
"""Return all incomplete (not yet submitted) attempts for this user. Excludes course quizzes."""
|
||||
incomplete = (
|
||||
db.query(QuizAttempt)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
.filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.is_(None),
|
||||
Quiz.course_id.is_(None), # exclude course quizzes
|
||||
)
|
||||
.order_by(QuizAttempt.started_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for a in incomplete:
|
||||
|
|
@ -411,11 +451,18 @@ def get_quiz_history(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return per-quiz attempt history for the performance line graph."""
|
||||
completed = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).order_by(QuizAttempt.completed_at).all()
|
||||
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes)."""
|
||||
completed = (
|
||||
db.query(QuizAttempt)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
.filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
)
|
||||
.order_by(QuizAttempt.completed_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Group by quiz
|
||||
from collections import defaultdict
|
||||
|
|
@ -446,16 +493,28 @@ def get_dashboard_stats(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
|
||||
# Count distinct quizzes the user has attempted (not created)
|
||||
total_quizzes = db.query(QuizAttempt.quiz_id).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).distinct().count()
|
||||
# Count distinct quizzes the user has attempted (excludes course quizzes)
|
||||
total_quizzes = (
|
||||
db.query(QuizAttempt.quiz_id)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
.filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
)
|
||||
.distinct().count()
|
||||
)
|
||||
|
||||
completed_attempts = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).all()
|
||||
completed_attempts = (
|
||||
db.query(QuizAttempt)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
.filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
total_attempts = len(completed_attempts)
|
||||
avg_score = 0.0
|
||||
|
|
@ -502,21 +561,26 @@ def get_attempt(
|
|||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
||||
|
||||
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
|
||||
is_course_quiz = quiz and quiz.course_id is not None
|
||||
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
|
||||
|
||||
# Show ALL questions in review, not just answered ones
|
||||
submitted_map = {ans.question_id: ans for ans in attempt.answers}
|
||||
answer_details = []
|
||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||
ans = submitted_map.get(q.id)
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=ans.user_answer if ans else "",
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=ans.is_correct if ans else False,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
if review_allowed:
|
||||
for q in get_quiz_questions(db, attempt.quiz_id):
|
||||
ans = submitted_map.get(q.id)
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=ans.user_answer if ans else "",
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=ans.is_correct if ans else False,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
|
||||
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||
return AttemptDetail(
|
||||
|
|
@ -528,4 +592,6 @@ def get_attempt(
|
|||
started_at=attempt.started_at,
|
||||
completed_at=attempt.completed_at,
|
||||
answers=answer_details,
|
||||
course_id=quiz.course_id if quiz else None,
|
||||
allow_review=review_allowed,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -19,6 +21,26 @@ from app.utils.auth import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_pwned_password(password: str) -> int | None:
|
||||
"""Check if password appears in Have I Been Pwned breach database.
|
||||
Uses k-anonymity: only first 5 chars of SHA-1 hash are sent.
|
||||
Returns breach count, 0 if clean, or None if check failed."""
|
||||
try:
|
||||
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
|
||||
prefix, suffix = sha1[:5], sha1[5:]
|
||||
resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for line in resp.text.splitlines():
|
||||
hash_suffix, count = line.split(":")
|
||||
if hash_suffix == suffix:
|
||||
return int(count)
|
||||
return 0
|
||||
except Exception:
|
||||
return None # fail open — don't block registration if API is down
|
||||
|
||||
|
||||
def _check_login_rate_limit(client_ip: str):
|
||||
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
||||
try:
|
||||
|
|
@ -107,6 +129,15 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
|||
if len(user_data.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
# Check breached passwords (warn, don't block)
|
||||
pwned_count = _check_pwned_password(user_data.password)
|
||||
password_warning = None
|
||||
if pwned_count and pwned_count > 0:
|
||||
password_warning = (
|
||||
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something unique."
|
||||
)
|
||||
|
||||
email_normalized = user_data.email.lower().strip()
|
||||
existing = db.query(User).filter(User.email == email_normalized).first()
|
||||
if existing:
|
||||
|
|
@ -135,11 +166,17 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
|||
if is_first_user:
|
||||
# Auto-verified admin — log them in immediately
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
resp = {"access_token": access_token, "token_type": "bearer"}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
|
||||
# Regular user — must verify email before logging in
|
||||
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
||||
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
||||
resp = {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
|
|
@ -252,10 +289,17 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
|
|||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
pwned_count = _check_pwned_password(data.new_password)
|
||||
user.hashed_password = get_password_hash(data.new_password)
|
||||
record.used = True
|
||||
db.commit()
|
||||
return {"message": "Password reset successfully. You can now log in."}
|
||||
resp = {"message": "Password reset successfully. You can now log in."}
|
||||
if pwned_count and pwned_count > 0:
|
||||
resp["password_warning"] = (
|
||||
f"Warning: this password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something more unique."
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/me/settings")
|
||||
|
|
@ -293,8 +337,9 @@ def get_me(current_user: User = Depends(get_current_user)):
|
|||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
@router.put("/me")
|
||||
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
password_warning = None
|
||||
if data.new_password:
|
||||
if not data.current_password:
|
||||
raise HTTPException(status_code=400, detail="Current password required to set a new one")
|
||||
|
|
@ -302,6 +347,12 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
|||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
pwned_count = _check_pwned_password(data.new_password)
|
||||
if pwned_count and pwned_count > 0:
|
||||
password_warning = (
|
||||
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something more unique."
|
||||
)
|
||||
current_user.hashed_password = get_password_hash(data.new_password)
|
||||
|
||||
if data.name:
|
||||
|
|
@ -309,4 +360,10 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
|||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
resp = {
|
||||
"id": current_user.id, "email": current_user.email,
|
||||
"name": current_user.name, "role": current_user.role,
|
||||
}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from datetime import datetime
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -16,6 +16,8 @@ from app.database import get_db
|
|||
from app.models.course import (
|
||||
Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress,
|
||||
)
|
||||
from app.models.question import Question
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
|
|
@ -26,27 +28,25 @@ router = APIRouter()
|
|||
# ── Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class CourseCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
description: str | None = Field(None, max_length=5000)
|
||||
|
||||
|
||||
class CourseUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
title: str | None = Field(None, max_length=300)
|
||||
description: str | None = Field(None, max_length=5000)
|
||||
requires_subscription: int | None = None
|
||||
|
||||
|
||||
class ModuleCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
position: int | None = None
|
||||
|
||||
|
||||
class ModuleUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
title: str | None = Field(None, max_length=300)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
position: int | None = None
|
||||
|
||||
|
||||
|
|
@ -55,17 +55,21 @@ class ModuleReorder(BaseModel):
|
|||
|
||||
|
||||
class LessonCreate(BaseModel):
|
||||
title: str
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
lesson_type: str = "video" # video, document, quiz, live
|
||||
content_text: str | None = None
|
||||
video_url: str | None = None
|
||||
quiz_id: int | None = None
|
||||
live_session_url: str | None = None
|
||||
live_session_start: str | None = None
|
||||
live_session_end: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
is_required: int = 1
|
||||
position: int | None = None
|
||||
|
||||
|
||||
class LessonUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
title: str | None = Field(None, max_length=300)
|
||||
lesson_type: str | None = None
|
||||
content_text: str | None = None
|
||||
video_url: str | None = None
|
||||
|
|
@ -73,6 +77,10 @@ class LessonUpdate(BaseModel):
|
|||
duration_minutes: int | None = None
|
||||
is_required: int | None = None
|
||||
position: int | None = None
|
||||
quiz_id: int | None = None
|
||||
live_session_url: str | None = None
|
||||
live_session_start: str | None = None
|
||||
live_session_end: str | None = None
|
||||
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
|
|
@ -85,6 +93,7 @@ class AIGenerateRequest(BaseModel):
|
|||
prompt: str
|
||||
action: str = "generate" # generate, refine, summarize
|
||||
existing_content: str | None = None
|
||||
model_id: str | None = None # optional override model
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
|
@ -121,7 +130,9 @@ def create_course(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new course (any authenticated user). Starts as draft."""
|
||||
"""Create a new course (moderators and admins only). Starts as draft."""
|
||||
if not current_user.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="Only moderators and admins can create courses")
|
||||
course = Course(
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
|
|
@ -164,6 +175,13 @@ def list_courses(
|
|||
.all()
|
||||
)
|
||||
|
||||
# Pre-fetch module counts
|
||||
mod_counts = dict(
|
||||
db.query(CourseModule.course_id, func.count(CourseModule.id))
|
||||
.group_by(CourseModule.course_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
user_cache: dict[int, str] = {}
|
||||
result = []
|
||||
for course, enrolled in rows:
|
||||
|
|
@ -180,13 +198,13 @@ def list_courses(
|
|||
"id": course.id,
|
||||
"title": course.title,
|
||||
"description": course.description,
|
||||
|
||||
"status": course.status,
|
||||
"thumbnail_path": course.thumbnail_path,
|
||||
"requires_subscription": course.requires_subscription,
|
||||
"user_id": course.user_id,
|
||||
"creator_name": user_cache[course.user_id],
|
||||
"enrolled_count": int(enrolled),
|
||||
"module_count": mod_counts.get(course.id, 0),
|
||||
"created_at": course.created_at,
|
||||
"updated_at": course.updated_at,
|
||||
})
|
||||
|
|
@ -226,6 +244,23 @@ def list_published_courses(
|
|||
.all()
|
||||
)
|
||||
|
||||
# Pre-fetch module counts
|
||||
course_ids = [c.id for c, _ in rows]
|
||||
mod_counts = dict(
|
||||
db.query(CourseModule.course_id, func.count(CourseModule.id))
|
||||
.filter(CourseModule.course_id.in_(course_ids))
|
||||
.group_by(CourseModule.course_id)
|
||||
.all()
|
||||
) if course_ids else {}
|
||||
|
||||
# Check which courses current user is enrolled in
|
||||
my_enrolled_ids = set(
|
||||
db.query(CourseEnrollment.course_id)
|
||||
.filter(CourseEnrollment.user_id == current_user.id, CourseEnrollment.course_id.in_(course_ids))
|
||||
.all()
|
||||
)
|
||||
my_enrolled_ids = {row[0] for row in my_enrolled_ids}
|
||||
|
||||
user_cache: dict[int, str] = {}
|
||||
courses = []
|
||||
for course, enrolled in rows:
|
||||
|
|
@ -236,12 +271,13 @@ def list_published_courses(
|
|||
"id": course.id,
|
||||
"title": course.title,
|
||||
"description": course.description,
|
||||
|
||||
"thumbnail_path": course.thumbnail_path,
|
||||
"requires_subscription": course.requires_subscription,
|
||||
"user_id": course.user_id,
|
||||
"creator_name": user_cache[course.user_id],
|
||||
"enrolled_count": int(enrolled),
|
||||
"module_count": mod_counts.get(course.id, 0),
|
||||
"is_enrolled": course.id in my_enrolled_ids,
|
||||
"created_at": course.created_at,
|
||||
})
|
||||
|
||||
|
|
@ -260,18 +296,28 @@ def my_courses(
|
|||
.all()
|
||||
)
|
||||
|
||||
course_ids = [e.course_id for e in enrollments]
|
||||
mod_counts = dict(
|
||||
db.query(CourseModule.course_id, func.count(CourseModule.id))
|
||||
.filter(CourseModule.course_id.in_(course_ids))
|
||||
.group_by(CourseModule.course_id)
|
||||
.all()
|
||||
) if course_ids else {}
|
||||
|
||||
result = []
|
||||
for enrollment in enrollments:
|
||||
course = db.query(Course).filter(Course.id == enrollment.course_id).first()
|
||||
if not course:
|
||||
continue
|
||||
owner = db.query(User).filter(User.id == course.user_id).first()
|
||||
result.append({
|
||||
"id": course.id,
|
||||
"title": course.title,
|
||||
"description": course.description,
|
||||
|
||||
"thumbnail_path": course.thumbnail_path,
|
||||
"status": course.status,
|
||||
"creator_name": owner.name if owner else "Unknown",
|
||||
"module_count": mod_counts.get(course.id, 0),
|
||||
"enrollment_id": enrollment.id,
|
||||
"progress_pct": enrollment.progress_pct,
|
||||
"enrolled_at": enrollment.enrolled_at,
|
||||
|
|
@ -281,6 +327,57 @@ def my_courses(
|
|||
return result
|
||||
|
||||
|
||||
def _build_lesson_dict(les, db, user_id):
|
||||
"""Build lesson dict with quiz metadata if applicable."""
|
||||
d = {
|
||||
"id": les.id,
|
||||
"title": les.title,
|
||||
"lesson_type": les.lesson_type,
|
||||
"content_text": les.content_text,
|
||||
"video_url": les.video_url,
|
||||
"video_provider": les.video_provider,
|
||||
"local_file_path": les.local_file_path,
|
||||
"duration_minutes": les.duration_minutes,
|
||||
"quiz_id": les.quiz_id,
|
||||
"live_session_url": les.live_session_url,
|
||||
"live_session_start": les.live_session_start,
|
||||
"live_session_end": les.live_session_end,
|
||||
"description": les.description,
|
||||
"is_required": les.is_required,
|
||||
"position": les.position,
|
||||
"bbb_meeting_id": les.bbb_meeting_id,
|
||||
}
|
||||
# Add quiz info for quiz lessons
|
||||
if les.quiz_id and les.lesson_type == "quiz":
|
||||
from app.models.attempt import QuizAttempt
|
||||
quiz = db.query(Quiz).filter(Quiz.id == les.quiz_id).first()
|
||||
if quiz:
|
||||
attempts = (
|
||||
db.query(QuizAttempt)
|
||||
.filter(QuizAttempt.quiz_id == quiz.id, QuizAttempt.user_id == user_id)
|
||||
.order_by(QuizAttempt.completed_at.desc())
|
||||
.all()
|
||||
)
|
||||
d["quiz_title"] = quiz.title
|
||||
d["quiz_allow_review"] = quiz.allow_review
|
||||
d["quiz_mode"] = quiz.mode
|
||||
d["quiz_time_limit"] = quiz.time_limit_minutes
|
||||
d["quiz_max_attempts"] = quiz.max_attempts
|
||||
d["quiz_questions_count"] = quiz.questions_count
|
||||
d["quiz_questions_per_attempt"] = quiz.questions_per_attempt
|
||||
d["quiz_attempts"] = [
|
||||
{
|
||||
"id": a.id,
|
||||
"score": a.score,
|
||||
"total": a.total_questions,
|
||||
"percentage": round(a.score / a.total_questions * 100) if a.total_questions else 0,
|
||||
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
|
||||
}
|
||||
for a in attempts
|
||||
]
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/{course_id}")
|
||||
def get_course(
|
||||
course_id: int,
|
||||
|
|
@ -329,24 +426,7 @@ def get_course(
|
|||
"description": mod.description,
|
||||
"position": mod.position,
|
||||
"lessons": [
|
||||
{
|
||||
"id": les.id,
|
||||
"title": les.title,
|
||||
"lesson_type": les.lesson_type,
|
||||
"content_text": les.content_text,
|
||||
"video_url": les.video_url,
|
||||
"video_provider": les.video_provider,
|
||||
"local_file_path": les.local_file_path,
|
||||
"duration_minutes": les.duration_minutes,
|
||||
"quiz_id": les.quiz_id,
|
||||
"live_session_url": les.live_session_url,
|
||||
"live_session_start": les.live_session_start,
|
||||
"live_session_end": les.live_session_end,
|
||||
"description": les.description,
|
||||
"is_required": les.is_required,
|
||||
"position": les.position,
|
||||
"bbb_meeting_id": les.bbb_meeting_id,
|
||||
}
|
||||
_build_lesson_dict(les, db, current_user.id)
|
||||
for les in lessons
|
||||
],
|
||||
})
|
||||
|
|
@ -375,7 +455,7 @@ def get_course(
|
|||
"lesson_progress": {
|
||||
lp.lesson_id: {
|
||||
"status": lp.status,
|
||||
"time_spent": lp.time_spent,
|
||||
"time_spent": lp.time_spent_seconds,
|
||||
"score": lp.score,
|
||||
"completed_at": lp.completed_at,
|
||||
}
|
||||
|
|
@ -449,6 +529,20 @@ def publish_course(
|
|||
return {"id": course.id, "status": course.status}
|
||||
|
||||
|
||||
@router.post("/{course_id}/unpublish")
|
||||
def unpublish_course(
|
||||
course_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Set course status back to draft (creator or admin)."""
|
||||
course = _course_owner_or_admin(course_id, current_user, db)
|
||||
course.status = "draft"
|
||||
course.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"id": course.id, "status": course.status}
|
||||
|
||||
|
||||
@router.post("/{course_id}/thumbnail")
|
||||
async def upload_thumbnail(
|
||||
course_id: int,
|
||||
|
|
@ -591,6 +685,112 @@ def reorder_modules(
|
|||
return {"reordered": True, "count": len(data.module_ids)}
|
||||
|
||||
|
||||
class LessonReorder(BaseModel):
|
||||
lesson_ids: list[int]
|
||||
|
||||
|
||||
@router.put("/{course_id}/modules/{module_id}/lessons/reorder")
|
||||
def reorder_lessons(
|
||||
course_id: int,
|
||||
module_id: int,
|
||||
data: LessonReorder,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Reorder lessons within a module by providing list of lesson IDs in desired order."""
|
||||
_course_owner_or_admin(course_id, current_user, db)
|
||||
for idx, lesson_id in enumerate(data.lesson_ids):
|
||||
lesson = (
|
||||
db.query(CourseLesson)
|
||||
.filter(CourseLesson.id == lesson_id, CourseLesson.module_id == module_id)
|
||||
.first()
|
||||
)
|
||||
if lesson:
|
||||
lesson.position = idx + 1
|
||||
db.commit()
|
||||
return {"reordered": True, "count": len(data.lesson_ids)}
|
||||
|
||||
|
||||
# ── Course Quiz Creation ─────────────────────────────────────────────
|
||||
|
||||
class CourseQuizCreate(BaseModel):
|
||||
question_ids: list[int]
|
||||
title: str | None = None
|
||||
mode: str = "learning" # timed or learning
|
||||
time_limit_minutes: int | None = None
|
||||
max_attempts: int | None = None # null = unlimited
|
||||
questions_per_attempt: int | None = None # null = all questions; set to pick random subset
|
||||
allow_review: bool = True # whether students can review answers after submit
|
||||
|
||||
|
||||
@router.post("/{course_id}/quiz")
|
||||
def create_course_quiz(
|
||||
course_id: int,
|
||||
data: CourseQuizCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a course-only quiz by copying selected questions. The quiz and copies
|
||||
are independent from the main question bank and quiz list."""
|
||||
_course_owner_or_admin(course_id, current_user, db)
|
||||
|
||||
if not data.question_ids:
|
||||
raise HTTPException(status_code=400, detail="Select at least one question")
|
||||
|
||||
source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
|
||||
if not source_questions:
|
||||
raise HTTPException(status_code=404, detail="No valid questions found")
|
||||
|
||||
# Preserve caller order
|
||||
id_order = {qid: i for i, qid in enumerate(data.question_ids)}
|
||||
source_questions.sort(key=lambda q: id_order.get(q.id, 999))
|
||||
|
||||
# Create quiz tied to this course
|
||||
qpa = data.questions_per_attempt
|
||||
if qpa and qpa > len(source_questions):
|
||||
qpa = None # can't ask for more than the pool
|
||||
|
||||
quiz = Quiz(
|
||||
user_id=current_user.id,
|
||||
title=data.title or f"Course Quiz ({len(source_questions)} questions)",
|
||||
questions_count=len(source_questions),
|
||||
mode=data.mode if data.mode in ("timed", "learning") else "learning",
|
||||
time_limit_minutes=data.time_limit_minutes,
|
||||
max_attempts=data.max_attempts,
|
||||
questions_per_attempt=qpa,
|
||||
allow_review=1 if data.allow_review else 0,
|
||||
is_published=0,
|
||||
is_shared=0,
|
||||
course_id=course_id,
|
||||
)
|
||||
db.add(quiz)
|
||||
db.flush()
|
||||
|
||||
# Copy each question (independent copies)
|
||||
from app.models.quiz_question_link import QuizQuestionLink
|
||||
for pos, sq in enumerate(source_questions):
|
||||
copy = Question(
|
||||
question_text=sq.question_text,
|
||||
question_type=sq.question_type,
|
||||
options=sq.options,
|
||||
correct_answer=sq.correct_answer,
|
||||
explanation=sq.explanation,
|
||||
image_path=sq.image_path,
|
||||
question_category_id=sq.question_category_id,
|
||||
source_quiz_id=quiz.id,
|
||||
user_id=current_user.id,
|
||||
is_shared=0,
|
||||
)
|
||||
db.add(copy)
|
||||
db.flush()
|
||||
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=copy.id, position=pos))
|
||||
|
||||
quiz.questions_count = len(source_questions)
|
||||
db.commit()
|
||||
db.refresh(quiz)
|
||||
return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count}
|
||||
|
||||
|
||||
# ── Lesson CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{course_id}/modules/{module_id}/lessons")
|
||||
|
|
@ -631,6 +831,10 @@ def create_lesson(
|
|||
content_text=data.content_text,
|
||||
video_url=data.video_url,
|
||||
video_provider=video_provider,
|
||||
quiz_id=data.quiz_id,
|
||||
live_session_url=data.live_session_url,
|
||||
live_session_start=data.live_session_start,
|
||||
live_session_end=data.live_session_end,
|
||||
duration_minutes=data.duration_minutes,
|
||||
is_required=data.is_required,
|
||||
position=position,
|
||||
|
|
@ -751,6 +955,165 @@ async def upload_lesson_file(
|
|||
|
||||
# ── Enrollment ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{course_id}/enrollees")
|
||||
def list_enrollees(
|
||||
course_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all enrollees with progress and quiz scores. Creator or admin only."""
|
||||
course = _course_owner_or_admin(course_id, current_user, db)
|
||||
|
||||
enrollments = (
|
||||
db.query(CourseEnrollment)
|
||||
.filter(CourseEnrollment.course_id == course_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get all quiz lessons for this course
|
||||
from app.models.attempt import QuizAttempt
|
||||
quiz_lessons = (
|
||||
db.query(CourseLesson)
|
||||
.join(CourseModule, CourseLesson.module_id == CourseModule.id)
|
||||
.filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None))
|
||||
.all()
|
||||
)
|
||||
quiz_ids = [l.quiz_id for l in quiz_lessons]
|
||||
quiz_titles = {}
|
||||
for l in quiz_lessons:
|
||||
q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first()
|
||||
quiz_titles[l.quiz_id] = q.title if q else l.title
|
||||
|
||||
result = []
|
||||
for enrollment in enrollments:
|
||||
user = db.query(User).filter(User.id == enrollment.user_id).first()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Lesson progress
|
||||
lesson_progress = (
|
||||
db.query(CourseLessonProgress)
|
||||
.filter(CourseLessonProgress.enrollment_id == enrollment.id)
|
||||
.all()
|
||||
)
|
||||
completed_count = sum(1 for lp in lesson_progress if lp.status == "completed")
|
||||
|
||||
# Quiz scores
|
||||
quiz_scores = []
|
||||
for qid in quiz_ids:
|
||||
best = (
|
||||
db.query(QuizAttempt)
|
||||
.filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None))
|
||||
.order_by(QuizAttempt.score.desc())
|
||||
.first()
|
||||
)
|
||||
if best:
|
||||
quiz_scores.append({
|
||||
"quiz_id": qid,
|
||||
"quiz_title": quiz_titles.get(qid, f"Quiz {qid}"),
|
||||
"best_score": best.score,
|
||||
"total": best.total_questions,
|
||||
"percentage": round(best.score / best.total_questions * 100) if best.total_questions else 0,
|
||||
"attempts": db.query(func.count(QuizAttempt.id)).filter(
|
||||
QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)
|
||||
).scalar(),
|
||||
})
|
||||
|
||||
result.append({
|
||||
"user_id": enrollment.user_id,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"progress_pct": enrollment.progress_pct or 0,
|
||||
"completed_lessons": completed_count,
|
||||
"enrolled_at": enrollment.enrolled_at,
|
||||
"completed_at": enrollment.completed_at,
|
||||
"quiz_scores": quiz_scores,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{course_id}/enrollees/export")
|
||||
def export_enrollees_csv(
|
||||
course_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Export enrollee data as CSV. Creator or admin only."""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.models.attempt import QuizAttempt
|
||||
|
||||
course = _course_owner_or_admin(course_id, current_user, db)
|
||||
|
||||
enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == course_id).all()
|
||||
|
||||
quiz_lessons = (
|
||||
db.query(CourseLesson)
|
||||
.join(CourseModule, CourseLesson.module_id == CourseModule.id)
|
||||
.filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None))
|
||||
.all()
|
||||
)
|
||||
quiz_ids = [l.quiz_id for l in quiz_lessons]
|
||||
quiz_titles = {}
|
||||
for l in quiz_lessons:
|
||||
q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first()
|
||||
quiz_titles[l.quiz_id] = q.title if q else l.title
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
writer = csv_mod.writer(buf)
|
||||
|
||||
# Header
|
||||
header = ["Name", "Email", "Progress %", "Completed Lessons", "Enrolled Date", "Completed Date"]
|
||||
for qid in quiz_ids:
|
||||
header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Best %)")
|
||||
header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Attempts)")
|
||||
writer.writerow(header)
|
||||
|
||||
for enrollment in enrollments:
|
||||
user = db.query(User).filter(User.id == enrollment.user_id).first()
|
||||
if not user:
|
||||
continue
|
||||
lp_count = db.query(func.count(CourseLessonProgress.id)).filter(
|
||||
CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.status == "completed"
|
||||
).scalar()
|
||||
|
||||
row = [
|
||||
user.name, user.email,
|
||||
enrollment.progress_pct or 0,
|
||||
lp_count,
|
||||
enrollment.enrolled_at.strftime("%Y-%m-%d") if enrollment.enrolled_at else "",
|
||||
enrollment.completed_at.strftime("%Y-%m-%d") if enrollment.completed_at else "",
|
||||
]
|
||||
for qid in quiz_ids:
|
||||
best = (
|
||||
db.query(QuizAttempt)
|
||||
.filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None))
|
||||
.order_by(QuizAttempt.score.desc())
|
||||
.first()
|
||||
)
|
||||
if best:
|
||||
row.append(round(best.score / best.total_questions * 100) if best.total_questions else 0)
|
||||
row.append(db.query(func.count(QuizAttempt.id)).filter(
|
||||
QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)
|
||||
).scalar())
|
||||
else:
|
||||
row.append("")
|
||||
row.append(0)
|
||||
writer.writerow(row)
|
||||
|
||||
buf.seek(0)
|
||||
# Add UTF-8 BOM for LibreOffice/Excel compatibility
|
||||
csv_content = "\ufeff" + buf.getvalue()
|
||||
filename = f"{course.title.replace(' ', '_')}_enrollees.csv"
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{course_id}/enroll")
|
||||
def enroll(
|
||||
course_id: int,
|
||||
|
|
@ -868,7 +1231,7 @@ def update_progress(
|
|||
if data.status == "completed" and not progress.completed_at:
|
||||
progress.completed_at = datetime.utcnow()
|
||||
if data.time_spent is not None:
|
||||
progress.time_spent = (progress.time_spent or 0) + data.time_spent
|
||||
progress.time_spent_seconds = (progress.time_spent_seconds or 0) + data.time_spent
|
||||
if data.score is not None:
|
||||
progress.score = data.score
|
||||
|
||||
|
|
@ -904,7 +1267,7 @@ def update_progress(
|
|||
return {
|
||||
"lesson_id": lesson_id,
|
||||
"status": progress.status,
|
||||
"time_spent": progress.time_spent,
|
||||
"time_spent": progress.time_spent_seconds,
|
||||
"score": progress.score,
|
||||
"enrollment_progress_pct": enrollment.progress_pct,
|
||||
}
|
||||
|
|
@ -925,11 +1288,23 @@ def ai_generate_standalone(
|
|||
from app.services.ai_service import get_model_for_task, _proxy_model
|
||||
import litellm
|
||||
|
||||
model_id, api_key = get_model_for_task(db, "teach")
|
||||
if data.model_id:
|
||||
model_id = data.model_id
|
||||
# Look up API key for this specific model
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first()
|
||||
api_key = config.api_key if config else None
|
||||
else:
|
||||
model_id, api_key = get_model_for_task(db, "teach")
|
||||
|
||||
system_msg = (
|
||||
"You are a medical education content specialist for pediatrics. "
|
||||
"Generate high-quality lesson content in markdown format. "
|
||||
"Include relevant medical information, key concepts, and learning objectives."
|
||||
"Generate high-quality lesson content in clean standard markdown. "
|
||||
"Use ## for section headings (never # — the title is separate). "
|
||||
"Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, "
|
||||
"and standard markdown tables (| col | col |). "
|
||||
"Include learning objectives, key concepts, tables, and clinical pearls. "
|
||||
"If the user provides existing content, you can see it and modify/extend it as instructed."
|
||||
)
|
||||
user_msg = data.prompt
|
||||
if data.existing_content:
|
||||
|
|
@ -942,6 +1317,8 @@ def ai_generate_standalone(
|
|||
], "max_tokens": 4000}
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
elif settings.LITELLM_API_KEY:
|
||||
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
response = litellm.completion(**kwargs)
|
||||
|
|
@ -974,7 +1351,13 @@ def ai_generate_content(
|
|||
from app.services.ai_service import get_model_for_task, _proxy_model
|
||||
import litellm
|
||||
|
||||
model_id, api_key = get_model_for_task(db, "teach")
|
||||
if data.model_id:
|
||||
model_id = data.model_id
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first()
|
||||
api_key = config.api_key if config else None
|
||||
else:
|
||||
model_id, api_key = get_model_for_task(db, "teach")
|
||||
|
||||
if data.action == "refine" and data.existing_content:
|
||||
system_msg = (
|
||||
|
|
@ -1000,8 +1383,12 @@ def ai_generate_content(
|
|||
else:
|
||||
system_msg = (
|
||||
"You are a medical education content specialist for pediatrics. "
|
||||
"Generate high-quality lesson content in markdown format. "
|
||||
"Include relevant medical information, key concepts, and learning objectives."
|
||||
"Generate high-quality lesson content in clean standard markdown. "
|
||||
"Use ## for section headings (never # — the title is separate). "
|
||||
"Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, "
|
||||
"and standard markdown tables (| col | col |). "
|
||||
"Include learning objectives, key concepts, tables, and clinical pearls. "
|
||||
"If the user provides existing content, you can see it and modify/extend it as instructed."
|
||||
)
|
||||
user_msg = (
|
||||
f"Lesson title: {lesson.title}\n\n"
|
||||
|
|
@ -1019,6 +1406,8 @@ def ai_generate_content(
|
|||
}
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
elif settings.LITELLM_API_KEY:
|
||||
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
|
||||
|
|
@ -1105,14 +1494,24 @@ def bbb_join_meeting(
|
|||
if not lesson.bbb_meeting_id:
|
||||
raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson")
|
||||
|
||||
# Verify user is enrolled or is the course owner/admin
|
||||
course = db.query(Course).filter(Course.id == course_id).first()
|
||||
is_owner = course and (course.user_id == current_user.id or current_user.is_admin)
|
||||
if not is_owner:
|
||||
enrolled = db.query(CourseEnrollment).filter(
|
||||
CourseEnrollment.course_id == course_id,
|
||||
CourseEnrollment.user_id == current_user.id,
|
||||
).first()
|
||||
if not enrolled:
|
||||
raise HTTPException(status_code=403, detail="You are not enrolled in this course")
|
||||
|
||||
bbb_url = getattr(settings, "BBB_SERVER_URL", None)
|
||||
bbb_secret = getattr(settings, "BBB_SECRET", None)
|
||||
if not bbb_url or not bbb_secret:
|
||||
raise HTTPException(status_code=501, detail="BBB not configured")
|
||||
|
||||
# Course owner/admin joins as moderator, others as attendee
|
||||
course = db.query(Course).filter(Course.id == course_id).first()
|
||||
is_moderator = course and (course.user_id == current_user.id or current_user.is_admin)
|
||||
is_moderator = is_owner
|
||||
password = "mp" if is_moderator else "ap"
|
||||
|
||||
params = urlencode({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import cast, String, or_, text as sa_text
|
||||
from sqlalchemy import cast, String, or_, text as sa_text, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
|
|
@ -15,6 +21,84 @@ from app.utils.auth import get_current_user, require_moderator
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
@router.delete("/{question_id}", status_code=204)
|
||||
def delete_question(
|
||||
question_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a question. Moderators can delete any; regular users can delete their own."""
|
||||
question = db.query(Question).filter(Question.id == question_id).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
is_mod = current_user.role in ("admin", "moderator")
|
||||
if not is_mod:
|
||||
# Regular users can only delete questions they created (no source quiz)
|
||||
if question.source_quiz_id is not None:
|
||||
raise HTTPException(status_code=403, detail="Only moderators can delete extracted questions")
|
||||
db.delete(question)
|
||||
db.commit()
|
||||
|
||||
|
||||
class QuestionEdit(BaseModel):
|
||||
question_text: str | None = None
|
||||
question_type: str | None = None
|
||||
options: list[str] | None = None
|
||||
correct_answer: str | None = None
|
||||
explanation: str | None = None
|
||||
question_category_id: int | None = None
|
||||
image_path: str | None = None
|
||||
|
||||
|
||||
@router.patch("/{question_id}")
|
||||
def edit_question(
|
||||
question_id: int,
|
||||
data: QuestionEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Edit a question. Owner or moderator can edit."""
|
||||
question = db.query(Question).filter(Question.id == question_id).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
is_mod = current_user.role in ("admin", "moderator")
|
||||
if question.user_id != current_user.id and not is_mod:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to edit this question")
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(question, field, value)
|
||||
db.commit()
|
||||
db.refresh(question)
|
||||
return {
|
||||
"id": question.id,
|
||||
"question_text": question.question_text,
|
||||
"question_type": question.question_type,
|
||||
"options": question.options,
|
||||
"correct_answer": question.correct_answer,
|
||||
"explanation": question.explanation,
|
||||
"question_category_id": question.question_category_id,
|
||||
"image_path": question.image_path,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{question_id}/share")
|
||||
def toggle_question_share(
|
||||
question_id: int,
|
||||
shared: int = Query(..., description="1 = shared, 0 = private"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle question sharing. Owner or admin can change."""
|
||||
question = db.query(Question).filter(Question.id == question_id).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if question.user_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not your question")
|
||||
question.is_shared = 1 if shared else 0
|
||||
db.commit()
|
||||
return {"id": question_id, "is_shared": question.is_shared}
|
||||
|
||||
|
||||
@router.patch("/{question_id}/category")
|
||||
def set_question_category(
|
||||
question_id: int,
|
||||
|
|
@ -98,6 +182,7 @@ def get_question_bank(
|
|||
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
||||
uncategorized: bool = Query(False),
|
||||
favorites_only: bool = Query(False),
|
||||
my_questions: bool = Query(False, description="Show only questions created by current user"),
|
||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
||||
limit: int = Query(50, le=200),
|
||||
|
|
@ -106,7 +191,16 @@ def get_question_bank(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
|
||||
query = db.query(Question)
|
||||
if my_questions:
|
||||
query = db.query(Question).filter(Question.user_id == current_user.id)
|
||||
else:
|
||||
query = db.query(Question).filter(
|
||||
or_(
|
||||
Question.is_shared == 1,
|
||||
Question.is_shared.is_(None), # legacy questions without is_shared
|
||||
Question.user_id == current_user.id, # always show own questions
|
||||
)
|
||||
)
|
||||
|
||||
if quiz_id:
|
||||
query = query.filter(Question.source_quiz_id == quiz_id)
|
||||
|
|
@ -216,11 +310,30 @@ def get_question_bank(
|
|||
"correct_answer": qu.correct_answer,
|
||||
"explanation": qu.explanation,
|
||||
"image_path": qu.image_path,
|
||||
"user_id": qu.user_id,
|
||||
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
||||
})
|
||||
|
||||
return {"total": total, "questions": result}
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Remove HTML tags and decode entities, returning plain text."""
|
||||
if not text:
|
||||
return text
|
||||
# Remove tags
|
||||
clean = re.sub(r"<[^>]+>", " ", text)
|
||||
# Decode common entities
|
||||
clean = clean.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", '"')
|
||||
# Collapse whitespace
|
||||
clean = re.sub(r"\s+", " ", clean).strip()
|
||||
return clean
|
||||
|
||||
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "uploads", "questions")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class ManualQuestionCreate(BaseModel):
|
||||
question_text: str
|
||||
question_type: str = "mcq"
|
||||
|
|
@ -228,6 +341,7 @@ class ManualQuestionCreate(BaseModel):
|
|||
correct_answer: str
|
||||
explanation: str | None = None
|
||||
question_category_id: int | None = None
|
||||
image_path: str | None = None
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
|
|
@ -237,7 +351,8 @@ def create_question_manually(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a single question manually (not from PDF extraction)."""
|
||||
if not data.question_text.strip():
|
||||
q_text = data.question_text.strip()
|
||||
if not q_text:
|
||||
raise HTTPException(status_code=400, detail="Question text is required")
|
||||
if not data.correct_answer.strip():
|
||||
raise HTTPException(status_code=400, detail="Correct answer is required")
|
||||
|
|
@ -247,12 +362,15 @@ def create_question_manually(
|
|||
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
|
||||
|
||||
question = Question(
|
||||
question_text=data.question_text.strip(),
|
||||
question_text=q_text,
|
||||
question_type=data.question_type,
|
||||
options=data.options,
|
||||
correct_answer=data.correct_answer.strip(),
|
||||
explanation=data.explanation,
|
||||
question_category_id=data.question_category_id,
|
||||
image_path=data.image_path,
|
||||
user_id=current_user.id,
|
||||
is_shared=1,
|
||||
)
|
||||
db.add(question)
|
||||
db.commit()
|
||||
|
|
@ -272,9 +390,46 @@ def create_question_manually(
|
|||
"question_type": question.question_type,
|
||||
"options": question.options,
|
||||
"correct_answer": question.correct_answer,
|
||||
"image_path": question.image_path,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
def upload_question_image(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload an image for use in questions. Returns the relative path."""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "png"
|
||||
if ext not in ("png", "jpg", "jpeg", "gif", "webp", "svg"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported image format")
|
||||
filename = f"{uuid.uuid4().hex[:12]}.{ext}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(file.file.read())
|
||||
rel_path = f"questions/{filename}"
|
||||
return {"image_path": rel_path, "url": f"/uploads/{rel_path}"}
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
def list_question_images(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all unique question images for the image bank browser."""
|
||||
images = (
|
||||
db.query(Question.image_path)
|
||||
.filter(Question.image_path.isnot(None), Question.image_path != "")
|
||||
.distinct()
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images]
|
||||
|
||||
|
||||
class CreateFromBankRequest(BaseModel):
|
||||
title: str
|
||||
question_ids: list[int]
|
||||
|
|
@ -354,3 +509,150 @@ def bulk_set_question_category(
|
|||
)
|
||||
db.commit()
|
||||
return {"updated": updated, "question_category_id": data.category_id}
|
||||
|
||||
|
||||
@router.get("/import/sample")
|
||||
def download_sample_csv():
|
||||
"""Download a sample CSV template for question import."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["question_text", "option_a", "option_b", "option_c", "option_d", "option_e", "correct_answer", "explanation", "category"])
|
||||
writer.writerow([
|
||||
"What is the most common cause of neonatal jaundice?",
|
||||
"Physiological jaundice", "ABO incompatibility", "G6PD deficiency", "Breast milk jaundice", "",
|
||||
"A",
|
||||
"Physiological jaundice occurs in up to 60% of term neonates due to immature hepatic conjugation.",
|
||||
"Neonatology",
|
||||
])
|
||||
writer.writerow([
|
||||
"Which vaccine is given at birth?",
|
||||
"BCG", "Hepatitis B", "Both BCG and Hepatitis B", "OPV", "",
|
||||
"C",
|
||||
"Both BCG and Hepatitis B are given at birth per the WHO immunization schedule.",
|
||||
"Immunology",
|
||||
])
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=question_import_sample.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/upload")
|
||||
def upload_questions_csv(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Import questions from CSV or XLSX file.
|
||||
|
||||
Expected columns: question_text, option_a, option_b, option_c, option_d, option_e,
|
||||
correct_answer (A/B/C/D/E or full text), explanation, category
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file provided")
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
||||
raw = file.file.read()
|
||||
|
||||
rows = []
|
||||
if ext in ("xlsx", "xls"):
|
||||
try:
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True)
|
||||
ws = wb.active
|
||||
header = None
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
if header is None:
|
||||
header = [str(c or "").strip().lower().replace(" ", "_") for c in row]
|
||||
continue
|
||||
rows.append(dict(zip(header, [str(c) if c is not None else "" for c in row])))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {e}")
|
||||
elif ext == "csv":
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
for row in reader:
|
||||
rows.append({k.strip().lower().replace(" ", "_"): (v or "").strip() for k, v in row.items()})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {e}")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file type. Use .csv or .xlsx")
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="File is empty or has no data rows")
|
||||
|
||||
# Cache category lookups
|
||||
cat_cache = {}
|
||||
for cat in db.query(QuestionCategory).all():
|
||||
cat_cache[cat.name.lower()] = cat.id
|
||||
|
||||
LETTER_MAP = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4}
|
||||
created = []
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(rows, start=2): # row 2 = first data row
|
||||
q_text = row.get("question_text", "").strip()
|
||||
if not q_text:
|
||||
errors.append(f"Row {i}: missing question_text")
|
||||
continue
|
||||
|
||||
options = []
|
||||
for key in ("option_a", "option_b", "option_c", "option_d", "option_e"):
|
||||
val = row.get(key, "").strip()
|
||||
if val:
|
||||
options.append(val)
|
||||
|
||||
correct_raw = row.get("correct_answer", "").strip()
|
||||
correct_answer = ""
|
||||
if correct_raw.lower() in LETTER_MAP and LETTER_MAP[correct_raw.lower()] < len(options):
|
||||
correct_answer = options[LETTER_MAP[correct_raw.lower()]]
|
||||
elif correct_raw in options:
|
||||
correct_answer = correct_raw
|
||||
else:
|
||||
errors.append(f"Row {i}: invalid correct_answer '{correct_raw}'")
|
||||
continue
|
||||
|
||||
explanation = row.get("explanation", "").strip() or None
|
||||
cat_name = row.get("category", "").strip()
|
||||
cat_id = None
|
||||
if cat_name:
|
||||
cat_lower = cat_name.lower()
|
||||
if cat_lower not in cat_cache:
|
||||
new_cat = QuestionCategory(name=cat_name, user_id=current_user.id)
|
||||
db.add(new_cat)
|
||||
db.flush()
|
||||
cat_cache[cat_lower] = new_cat.id
|
||||
cat_id = cat_cache[cat_lower]
|
||||
|
||||
question = Question(
|
||||
question_text=q_text,
|
||||
question_type="mcq" if len(options) >= 2 else "short_answer",
|
||||
options=options if len(options) >= 2 else None,
|
||||
correct_answer=correct_answer,
|
||||
explanation=explanation,
|
||||
question_category_id=cat_id,
|
||||
)
|
||||
db.add(question)
|
||||
created.append(question)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Embed in background
|
||||
for q in created:
|
||||
db.refresh(q)
|
||||
try:
|
||||
from app.services import embedding_service
|
||||
embedding_service.embed_question(q)
|
||||
except Exception:
|
||||
pass
|
||||
if created:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"imported": len(created),
|
||||
"errors": errors[:20], # cap error list
|
||||
"total_rows": len(rows),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ def search_quizzes(
|
|||
def _ensure_quiz(quiz_id: int, match_type: str):
|
||||
if quiz_id not in results:
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
if not quiz or quiz.course_id is not None:
|
||||
return False
|
||||
results[quiz_id] = {
|
||||
"quiz_id": quiz.id,
|
||||
|
|
@ -179,7 +179,7 @@ def search_quizzes(
|
|||
|
||||
# ── Title search ─────────────────────────────────────────────
|
||||
if mode in ("title", "all"):
|
||||
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all():
|
||||
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%"), Quiz.course_id.is_(None)).limit(30).all():
|
||||
_ensure_quiz(quiz.id, "title")
|
||||
|
||||
# ── Semantic (vector) search ──────────────────────────────────
|
||||
|
|
@ -268,7 +268,7 @@ def list_quizzes(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List quizzes. Moderators see all; regular users only see published."""
|
||||
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
|
||||
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None))
|
||||
if not current_user.is_moderator:
|
||||
q = q.filter(Quiz.is_published == 1)
|
||||
return q.order_by(Quiz.created_at.desc()).all()
|
||||
|
|
@ -278,17 +278,34 @@ def list_quizzes(
|
|||
def get_quiz(
|
||||
quiz_id: int,
|
||||
study: bool = Query(False),
|
||||
attempt_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get quiz for taking. study=true forces answers/explanations to be included."""
|
||||
"""Get quiz for taking. study=true forces answers/explanations to be included.
|
||||
attempt_id filters to the question subset selected for that attempt (question pool)."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
if study or quiz.mode == "learning":
|
||||
return QuizLearningDetail.model_validate(quiz)
|
||||
return QuizDetail.model_validate(quiz)
|
||||
result = QuizLearningDetail.model_validate(quiz)
|
||||
else:
|
||||
result = QuizDetail.model_validate(quiz)
|
||||
|
||||
# Filter questions if attempt has a question pool selection
|
||||
if attempt_id:
|
||||
from app.models.attempt import QuizAttempt
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
).first()
|
||||
if attempt and attempt.selected_question_ids:
|
||||
sel = set(attempt.selected_question_ids)
|
||||
result.questions = [q for q in result.questions if q.id in sel]
|
||||
result.questions_count = len(result.questions)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/{quiz_id}", response_model=QuizResponse)
|
||||
|
|
@ -296,12 +313,14 @@ def update_quiz(
|
|||
quiz_id: int,
|
||||
data: QuizUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update quiz metadata (title, etc.) — moderator only."""
|
||||
"""Update quiz metadata (title, etc.) — owner or admin only."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
if quiz.user_id != current_user.id and current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Not authorized to edit this quiz")
|
||||
|
||||
if data.title:
|
||||
quiz.title = data.title.strip()
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ class AttemptResponse(BaseModel):
|
|||
percentage: float
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
course_id: int | None = None
|
||||
allow_review: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class QuizResponse(BaseModel):
|
|||
deleted_at: datetime | None = None
|
||||
is_published: int = 1
|
||||
is_shared: int = 0
|
||||
questions_per_attempt: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status, Request, Response
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
|
@ -11,16 +11,15 @@ from app.config import settings
|
|||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ uvicorn[standard]==0.27.1
|
|||
sqlalchemy==2.0.27
|
||||
alembic==1.13.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
bcrypt>=4.0.1
|
||||
python-multipart==0.0.9
|
||||
pydantic[email]==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
|
|
@ -21,3 +20,4 @@ python-dotenv==1.0.1
|
|||
httpx==0.27.0
|
||||
boto3==1.34.69
|
||||
pgvector==0.3.6
|
||||
openpyxl==3.1.2
|
||||
|
|
|
|||
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
|
||||
restart: unless-stopped
|
||||
|
||||
db-backup:
|
||||
image: prodrigestivill/postgres-backup-local:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_DB: pedquiz
|
||||
POSTGRES_USER: pedquiz
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
SCHEDULE: "@daily"
|
||||
BACKUP_KEEP_DAYS: 14
|
||||
BACKUP_KEEP_WEEKS: 4
|
||||
BACKUP_KEEP_MONTHS: 6
|
||||
HEALTHCHECK_PORT: 8080
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
chroma_data:
|
||||
|
|
|
|||
|
|
@ -16,8 +16,18 @@
|
|||
"react-router-dom": "^6.22.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
"tinymce": "^7.6.1"
|
||||
"@milkdown/core": "^7.6.2",
|
||||
"@milkdown/ctx": "^7.6.2",
|
||||
"@milkdown/preset-commonmark": "^7.6.2",
|
||||
"@milkdown/preset-gfm": "^7.6.2",
|
||||
"@milkdown/plugin-listener": "^7.6.2",
|
||||
"@milkdown/plugin-history": "^7.6.2",
|
||||
"@milkdown/react": "^7.6.2",
|
||||
"@milkdown/theme-nord": "^7.6.2",
|
||||
"@milkdown/plugin-math": "^7.5.9",
|
||||
"katex": "^0.16.11",
|
||||
"remark-math": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{ to: '/question-bank', label: 'Question Bank' },
|
||||
{ to: '/flashcards', label: 'Flashcards' },
|
||||
{ to: '/courses', label: 'Courses' },
|
||||
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
|
||||
{ to: '/settings', label: '⚙ Settings' },
|
||||
] : []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +1,114 @@
|
|||
import { useRef } from 'react'
|
||||
import { Editor } from '@tinymce/tinymce-react'
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
|
||||
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBulletListCommand, wrapInOrderedListCommand, wrapInBlockquoteCommand, insertHrCommand } from '@milkdown/preset-commonmark'
|
||||
import { gfm, insertTableCommand, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
||||
import { history, undoCommand, redoCommand } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { math } from '@milkdown/plugin-math'
|
||||
import { nord } from '@milkdown/theme-nord'
|
||||
import { Milkdown, MilkdownProvider, useEditor, useInstance } from '@milkdown/react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import '@milkdown/theme-nord/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// Self-hosted TinyMCE — no API key needed
|
||||
import 'tinymce/tinymce'
|
||||
import 'tinymce/models/dom'
|
||||
import 'tinymce/themes/silver'
|
||||
import 'tinymce/icons/default'
|
||||
import 'tinymce/skins/ui/oxide/skin.min.css'
|
||||
import 'tinymce/plugins/lists'
|
||||
import 'tinymce/plugins/link'
|
||||
import 'tinymce/plugins/image'
|
||||
import 'tinymce/plugins/table'
|
||||
import 'tinymce/plugins/code'
|
||||
import 'tinymce/plugins/fullscreen'
|
||||
import 'tinymce/plugins/autolink'
|
||||
function Toolbar() {
|
||||
const [, getEditor] = useInstance()
|
||||
const call = (cmd, payload) => {
|
||||
const editor = getEditor()
|
||||
if (editor) editor.action(callCommand(cmd, payload))
|
||||
}
|
||||
|
||||
export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) {
|
||||
const editorRef = useRef(null)
|
||||
const btn = { background: 'none', border: '1px solid #e2e8f0', borderRadius: 4, padding: '4px 8px', cursor: 'pointer', fontSize: '0.82rem', color: '#475569', lineHeight: 1 }
|
||||
const sep = { width: 1, height: 20, background: '#e2e8f0', margin: '0 4px' }
|
||||
|
||||
return (
|
||||
<Editor
|
||||
onInit={(evt, editor) => { editorRef.current = editor }}
|
||||
value={value}
|
||||
onEditorChange={(content) => onChange(content)}
|
||||
init={{
|
||||
height,
|
||||
menubar: false,
|
||||
plugins: 'lists link image table code fullscreen autolink',
|
||||
toolbar: 'undo redo | blocks | bold italic underline | bullist numlist | link image table | code fullscreen',
|
||||
placeholder,
|
||||
skin: false,
|
||||
content_css: false,
|
||||
content_style: `
|
||||
body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #e2e8f0; background: #1e293b; padding: 12px; }
|
||||
a { color: #60a5fa; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td, th { border: 1px solid #475569; padding: 6px 10px; }
|
||||
img { max-width: 100%; }
|
||||
`,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
license_key: 'gpl',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '6px 10px', borderBottom: '1px solid #e2e8f0', background: '#f8fafc', flexWrap: 'wrap' }}>
|
||||
<button style={btn} onClick={() => call(undoCommand.key)} title="Undo (Ctrl+Z)">↩</button>
|
||||
<button style={btn} onClick={() => call(redoCommand.key)} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||
<div style={sep} />
|
||||
<button style={{ ...btn, fontWeight: 700 }} onClick={() => call(toggleStrongCommand.key)} title="Bold (Ctrl+B)">B</button>
|
||||
<button style={{ ...btn, fontStyle: 'italic' }} onClick={() => call(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">I</button>
|
||||
<button style={{ ...btn, textDecoration: 'line-through' }} onClick={() => call(toggleStrikethroughCommand.key)} title="Strikethrough">S</button>
|
||||
<div style={sep} />
|
||||
<button style={btn} onClick={() => call(wrapInBulletListCommand.key)} title="Bullet list">• List</button>
|
||||
<button style={btn} onClick={() => call(wrapInOrderedListCommand.key)} title="Numbered list">1. List</button>
|
||||
<button style={btn} onClick={() => call(wrapInBlockquoteCommand.key)} title="Blockquote">> Quote</button>
|
||||
<div style={sep} />
|
||||
<button style={btn} onClick={() => call(insertTableCommand.key)} title="Insert table">Table</button>
|
||||
<button style={btn} onClick={() => call(insertHrCommand.key)} title="Horizontal rule">― HR</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MilkdownEditor({ value, onChange, placeholder }) {
|
||||
const isInternalChange = useRef(false)
|
||||
const lastValue = useRef(value)
|
||||
|
||||
useEditor((root) =>
|
||||
Editor.make()
|
||||
.config(nord)
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root)
|
||||
ctx.set(defaultValueCtx, value || '')
|
||||
const l = ctx.get(listenerCtx)
|
||||
l.markdownUpdated((_, md) => {
|
||||
isInternalChange.current = true
|
||||
lastValue.current = md
|
||||
onChange(md)
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(math)
|
||||
.use(listener),
|
||||
[])
|
||||
|
||||
return <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-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
|
||||
}
|
||||
|
||||
await api.put('/auth/me', payload)
|
||||
const res = await api.put('/auth/me', payload)
|
||||
if (res.data.password_warning) {
|
||||
setError(res.data.password_warning)
|
||||
}
|
||||
setSuccess('Account updated successfully')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
// Refresh user info
|
||||
const res = await api.get('/auth/me')
|
||||
await api.get('/auth/me')
|
||||
// Update local token display by refreshing
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
const LESSON_ICONS = {
|
||||
|
|
@ -45,10 +52,12 @@ export default function CourseDetailPage() {
|
|||
// Build completed/in-progress sets from enrollment lesson progress if available
|
||||
const completed = new Set()
|
||||
const inProgress = new Set()
|
||||
if (data.enrollment?.lesson_progress) {
|
||||
for (const lp of data.enrollment.lesson_progress) {
|
||||
if (lp.status === 'completed') completed.add(lp.lesson_id)
|
||||
else if (lp.status === 'in_progress') inProgress.add(lp.lesson_id)
|
||||
if (data.my_enrollment?.lesson_progress) {
|
||||
const lp = data.my_enrollment.lesson_progress
|
||||
// lesson_progress is { lesson_id: { status, ... } }
|
||||
for (const [lessonId, info] of Object.entries(lp)) {
|
||||
if (info.status === 'completed') completed.add(parseInt(lessonId))
|
||||
else if (info.status === 'in_progress') inProgress.add(parseInt(lessonId))
|
||||
}
|
||||
}
|
||||
setCompletedLessons(completed)
|
||||
|
|
@ -124,8 +133,11 @@ export default function CourseDetailPage() {
|
|||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
|
||||
|
||||
const enrolled = !!course.enrollment
|
||||
const progressPct = course.enrollment?.progress_pct || 0
|
||||
const { user } = useAuth()
|
||||
const enrolled = !!course.my_enrollment
|
||||
const isCreator = user && (course.user_id === user.id || user.role === 'admin')
|
||||
const canView = enrolled || isCreator // creators can preview without enrolling
|
||||
const progressPct = course.my_enrollment?.progress_pct || 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -167,7 +179,7 @@ export default function CourseDetailPage() {
|
|||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!enrolled && (
|
||||
{!canView && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<p>Enroll in this course to access modules and lessons.</p>
|
||||
|
|
@ -175,8 +187,14 @@ export default function CourseDetailPage() {
|
|||
</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 */}
|
||||
{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 }}>
|
||||
{course.modules.map(mod => {
|
||||
const isExpanded = !!expandedModules[mod.id]
|
||||
|
|
@ -204,12 +222,12 @@ export default function CourseDetailPage() {
|
|||
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{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>
|
||||
{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>
|
||||
|
||||
|
|
@ -244,14 +262,14 @@ export default function CourseDetailPage() {
|
|||
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
|
||||
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
|
||||
{lesson.lesson_type === 'live_session' && lesson.scheduled_at && (
|
||||
<span> \u00B7 {new Date(lesson.scheduled_at).toLocaleString()}</span>
|
||||
{lesson.lesson_type === 'live_session' && lesson.live_session_start && (
|
||||
<span> · {new Date(lesson.live_session_start).toLocaleString()}</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
|
@ -264,12 +282,12 @@ export default function CourseDetailPage() {
|
|||
</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>
|
||||
)}
|
||||
|
||||
{/* Lesson viewer */}
|
||||
{enrolled && activeLesson && (
|
||||
{canView && activeLesson && (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
|
|
@ -283,12 +301,12 @@ export default function CourseDetailPage() {
|
|||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!completedLessons.has(activeLesson.id) && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
|
||||
Mark Complete \u2713
|
||||
Mark Complete ✓
|
||||
</button>
|
||||
)}
|
||||
{completedLessons.has(activeLesson.id) && (
|
||||
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
|
||||
\u2713 Completed
|
||||
✓ Completed
|
||||
</span>
|
||||
)}
|
||||
<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 && activeLesson.lesson_type === 'text' && (
|
||||
<div style={{ lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
|
||||
{activeLesson.content_text || 'No content available.'}
|
||||
<div className="lesson-content" style={{ lineHeight: 1.7 }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
|
@ -327,23 +351,73 @@ export default function CourseDetailPage() {
|
|||
)}
|
||||
|
||||
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<p style={{ marginBottom: 16, color: 'var(--text-muted)' }}>This lesson contains a quiz.</p>
|
||||
{activeLesson.quiz_id ? (
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}`)}>
|
||||
Start Quiz
|
||||
</button>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No quiz linked to this lesson.</p>
|
||||
<div style={{ padding: 20 }}>
|
||||
{activeLesson.quiz_id ? (() => {
|
||||
const attempts = activeLesson.quiz_attempts || []
|
||||
const maxAttempts = activeLesson.quiz_max_attempts
|
||||
const attemptsLeft = maxAttempts ? maxAttempts - attempts.length : null
|
||||
const canAttempt = !maxAttempts || attempts.length < maxAttempts
|
||||
const lastAttempt = attempts[0]
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
{activeLesson.scheduled_at && (
|
||||
{activeLesson.live_session_start && (
|
||||
<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>
|
||||
)}
|
||||
{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 { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
|
@ -6,6 +6,11 @@ import ConfirmButton from '../components/ConfirmButton'
|
|||
|
||||
const RichEditor = lazy(() => import('../components/RichEditor'))
|
||||
|
||||
function stripHtml(html) {
|
||||
if (!html) return ''
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const LESSON_TYPES = [
|
||||
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
|
||||
{ value: 'video', label: 'Video', icon: '🎬' },
|
||||
|
|
@ -17,38 +22,52 @@ const LESSON_TYPES = [
|
|||
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
|
||||
|
||||
// --- Question Bank Mini-Browser ---
|
||||
function QuestionBankBrowser({ onQuizCreated }) {
|
||||
function QuestionBankBrowser({ courseId, onQuizCreated }) {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [filterCatIds, setFilterCatIds] = useState([])
|
||||
const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([])
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selected, setSelected] = useState(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/question-categories/').then(r => setCategories(r.data)).catch(() => {})
|
||||
api.get('/tags').then(r => setTags(r.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchQuestions = useCallback(async () => {
|
||||
const fetchQuestions = useCallback(async (q = search, catIds = filterCatIds, tagIds = selectedTagIds) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { limit: 20 }
|
||||
if (categoryId) params.category_id = categoryId
|
||||
if (search.trim()) params.q = search.trim()
|
||||
const params = { limit: 30 }
|
||||
if (q.trim()) params.q = q.trim()
|
||||
if (catIds.length > 0) params.category_ids = catIds.join(',')
|
||||
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
|
||||
const res = await api.get('/questions/bank', { params })
|
||||
setQuestions(res.data.questions || res.data || [])
|
||||
setTotal(res.data.total || 0)
|
||||
} catch {
|
||||
setQuestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [categoryId, search])
|
||||
}, [search, filterCatIds, selectedTagIds])
|
||||
|
||||
const catIdsKey = filterCatIds.join(',')
|
||||
const tagIdsKey = selectedTagIds.join(',')
|
||||
useEffect(() => {
|
||||
fetchQuestions()
|
||||
}, [fetchQuestions])
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => fetchQuestions(search, filterCatIds, selectedTagIds), 300)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [search, catIdsKey, tagIdsKey])
|
||||
|
||||
const toggleTag = (tagId) => setSelectedTagIds(prev => prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId])
|
||||
|
||||
const toggle = (qId) => {
|
||||
setSelected(prev => {
|
||||
|
|
@ -58,15 +77,30 @@ function QuestionBankBrowser({ onQuizCreated }) {
|
|||
})
|
||||
}
|
||||
|
||||
// Quiz settings
|
||||
const [quizTitle, setQuizTitle] = useState('')
|
||||
const [quizMode, setQuizMode] = useState('learning')
|
||||
const [quizTimeLimit, setQuizTimeLimit] = useState('')
|
||||
const [quizMaxAttempts, setQuizMaxAttempts] = useState('')
|
||||
const [quizQuestionsPerAttempt, setQuizQuestionsPerAttempt] = useState('')
|
||||
const [quizAllowReview, setQuizAllowReview] = useState(true)
|
||||
|
||||
const createQuiz = async () => {
|
||||
if (selected.size === 0) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/questions/from-bank', {
|
||||
const res = await api.post(`/courses/${courseId}/quiz`, {
|
||||
question_ids: [...selected],
|
||||
title: quizTitle.trim() || undefined,
|
||||
mode: quizMode,
|
||||
time_limit_minutes: quizTimeLimit ? parseInt(quizTimeLimit) : null,
|
||||
max_attempts: quizMaxAttempts ? parseInt(quizMaxAttempts) : null,
|
||||
questions_per_attempt: quizQuestionsPerAttempt ? parseInt(quizQuestionsPerAttempt) : null,
|
||||
allow_review: quizAllowReview,
|
||||
})
|
||||
onQuizCreated(res.data.quiz_id || res.data.id)
|
||||
setQuizTitle('')
|
||||
onQuizCreated(res.data.id)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to create quiz')
|
||||
} finally {
|
||||
|
|
@ -74,51 +108,253 @@ function QuestionBankBrowser({ onQuizCreated }) {
|
|||
}
|
||||
}
|
||||
|
||||
// Inline add question
|
||||
const [showAddQ, setShowAddQ] = useState(false)
|
||||
const [newQ, setNewQ] = useState({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' })
|
||||
const [addingQ, setAddingQ] = useState(false)
|
||||
|
||||
const submitNewQuestion = async () => {
|
||||
const validOpts = newQ.options.filter(o => o.trim())
|
||||
if (!newQ.text.trim() || validOpts.length < 2 || newQ.correctIdx < 0) return
|
||||
setAddingQ(true)
|
||||
try {
|
||||
const res = await api.post('/questions/create', {
|
||||
question_text: newQ.text,
|
||||
question_type: 'mcq',
|
||||
options: validOpts,
|
||||
correct_answer: newQ.options[newQ.correctIdx],
|
||||
explanation: newQ.explanation || null,
|
||||
})
|
||||
// Auto-select the new question
|
||||
setSelected(prev => new Set([...prev, res.data.id]))
|
||||
setNewQ({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' })
|
||||
setShowAddQ(false)
|
||||
fetchQuestions()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to add question')
|
||||
} finally {
|
||||
setAddingQ(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasTags = tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 14, marginTop: 8, background: 'var(--bg)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} style={{ flex: 1, minWidth: 140 }}>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginTop: 8, background: 'var(--bg)' }}>
|
||||
{/* Search */}
|
||||
<div style={{ position: 'relative', marginBottom: 10 }}>
|
||||
<span style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '0.85rem' }}>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search questions..."
|
||||
style={{ flex: 2, minWidth: 160 }}
|
||||
style={{ width: '100%', padding: '9px 12px 9px 32px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.88rem', background: 'var(--input-bg)', color: 'var(--text)', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', marginBottom: 10 }}>
|
||||
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Loading...</div>}
|
||||
{!loading && questions.length === 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No questions found.</div>
|
||||
)}
|
||||
{questions.map(q => (
|
||||
<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)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(q.id)}
|
||||
onChange={() => toggle(q.id)}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<span style={{ flex: 1 }}>{q.question_text?.slice(0, 120) || `Question #${q.id}`}</span>
|
||||
</label>
|
||||
{/* Category chips */}
|
||||
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<button className={`btn btn-sm ${filterCatIds.length === 0 ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setFilterCatIds([])} style={{ fontSize: '0.72rem', padding: '3px 10px' }}>All</button>
|
||||
{categories.map(cat => (
|
||||
<button key={cat.id} className={`btn btn-sm ${filterCatIds.includes(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setFilterCatIds(prev => prev.includes(cat.id) ? prev.filter(c => c !== cat.id) : [...prev, cat.id])}
|
||||
style={{ fontSize: '0.72rem', padding: '3px 10px' }}>
|
||||
{cat.name} <span style={{ opacity: 0.6 }}>({cat.question_count})</span>
|
||||
</button>
|
||||
))}
|
||||
{/* 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>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 8, fontSize: '0.85rem' }}>{error}</div>}
|
||||
|
||||
<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>
|
||||
{/* Add question inline */}
|
||||
{showAddQ && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--card-bg)' }}>
|
||||
<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 }))}
|
||||
placeholder="Question text..." rows={2}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -139,8 +375,16 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
const [saving, setSaving] = useState(false)
|
||||
const [aiPrompt, setAiPrompt] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiModels, setAiModels] = useState([])
|
||||
const [aiModelId, setAiModelId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/models/available?task=teach').then(r => {
|
||||
setAiModels(r.data || [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) { setError('Title is required'); return }
|
||||
setSaving(true)
|
||||
|
|
@ -185,13 +429,23 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
const endpoint = lesson?.id
|
||||
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
|
||||
: `/courses/${courseId}/ai-generate`
|
||||
const res = await api.post(endpoint, {
|
||||
const body = {
|
||||
prompt: aiPrompt || `Write lesson content about: ${title}`,
|
||||
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) {
|
||||
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) {
|
||||
setError(err.response?.data?.detail || 'AI generation failed')
|
||||
|
|
@ -244,10 +498,17 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
type="text"
|
||||
value={aiPrompt}
|
||||
onChange={e => setAiPrompt(e.target.value)}
|
||||
placeholder="Describe what AI should write..."
|
||||
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' }}
|
||||
placeholder="Describe what AI should write or modify..."
|
||||
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')}
|
||||
/>
|
||||
{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
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => aiGenerate('generate')}
|
||||
|
|
@ -257,20 +518,14 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
</button>
|
||||
{contentText.trim() && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => aiGenerate('refine')}
|
||||
disabled={aiLoading}
|
||||
>
|
||||
AI Refine
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => aiGenerate('refine')}
|
||||
disabled={aiLoading}
|
||||
>
|
||||
AI Refine
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -301,9 +556,9 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
<div className="form-group">
|
||||
<label>Upload Document</label>
|
||||
<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' }}>
|
||||
Current: {lesson.file_name}
|
||||
Current: {lesson.local_file_path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -315,11 +570,19 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
<label>Quiz</label>
|
||||
{quizId ? (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -418,15 +681,11 @@ export default function CourseEditorPage() {
|
|||
clearMessages()
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { title: title.trim(), description: description.trim() }
|
||||
await api.put(`/courses/${courseId}`, { title: title.trim(), description: description.trim() })
|
||||
if (thumbnail) {
|
||||
const fd = new FormData()
|
||||
fd.append('title', payload.title)
|
||||
fd.append('description', payload.description)
|
||||
fd.append('thumbnail', thumbnail)
|
||||
await api.put(`/courses/${courseId}`, fd)
|
||||
} else {
|
||||
await api.put(`/courses/${courseId}`, payload)
|
||||
fd.append('file', thumbnail)
|
||||
await api.post(`/courses/${courseId}/thumbnail`, fd)
|
||||
}
|
||||
setSuccess('Course saved.')
|
||||
setThumbnail(null)
|
||||
|
|
@ -438,15 +697,16 @@ export default function CourseEditorPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const publishCourse = async () => {
|
||||
const togglePublish = async () => {
|
||||
clearMessages()
|
||||
setPublishing(true)
|
||||
const action = course.status === 'published' ? 'unpublish' : 'publish'
|
||||
try {
|
||||
await api.post(`/courses/${courseId}/publish`)
|
||||
setSuccess('Course published!')
|
||||
await api.post(`/courses/${courseId}/${action}`)
|
||||
setSuccess(action === 'publish' ? 'Course published!' : 'Course unpublished (draft).')
|
||||
fetchCourse()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to publish')
|
||||
setError(err.response?.data?.detail || `Failed to ${action}`)
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
|
|
@ -563,6 +823,11 @@ export default function CourseEditorPage() {
|
|||
|
||||
return (
|
||||
<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>}
|
||||
{success && <div className="alert alert-success">{success}</div>}
|
||||
|
||||
|
|
@ -605,9 +870,9 @@ export default function CourseEditorPage() {
|
|||
onChange={e => setThumbnail(e.target.files[0])}
|
||||
disabled={!isOwner}
|
||||
/>
|
||||
{course.thumbnail_url && !thumbnail && (
|
||||
{course.thumbnail_path && !thumbnail && (
|
||||
<img
|
||||
src={course.thumbnail_url}
|
||||
src={course.thumbnail_path}
|
||||
alt="Thumbnail"
|
||||
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}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
{course.status !== 'published' && (
|
||||
<button className="btn btn-secondary" onClick={publishCourse} disabled={publishing}>
|
||||
{publishing ? 'Publishing...' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
<button className={`btn ${course.status === 'published' ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={togglePublish} disabled={publishing} style={course.status === 'published' ? { color: 'var(--wrong-fg)' } : {}}>
|
||||
{publishing ? '...' : course.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -690,7 +954,7 @@ export default function CourseEditorPage() {
|
|||
onChange={e => setEditModuleTitle(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') updateModule(mod.id); if (e.key === 'Escape') setEditingModuleId(null) }}
|
||||
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
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -763,7 +1027,7 @@ export default function CourseEditorPage() {
|
|||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
||||
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' }}>
|
||||
{lesson.title}
|
||||
{lesson.is_required === false && (
|
||||
|
|
@ -771,7 +1035,7 @@ export default function CourseEditorPage() {
|
|||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{lesson.type}
|
||||
{lesson.lesson_type}
|
||||
{lesson.duration_minutes ? ` · ${lesson.duration_minutes}m` : ''}
|
||||
</span>
|
||||
|
||||
|
|
@ -831,6 +1095,87 @@ export default function CourseEditorPage() {
|
|||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
|
||||
export default function CoursesPage() {
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [tab, setTab] = useState('browse')
|
||||
const [courses, setCourses] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
|
@ -16,7 +18,6 @@ export default function CoursesPage() {
|
|||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const debounceRef = useRef(null)
|
||||
const LIMIT = 12
|
||||
|
|
@ -75,8 +76,9 @@ export default function CoursesPage() {
|
|||
|
||||
const togglePublish = async (courseId, currentlyPublished) => {
|
||||
try {
|
||||
await api.patch(`/courses/${courseId}`, { is_published: !currentlyPublished })
|
||||
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, is_published: !currentlyPublished } : c))
|
||||
const action = currentlyPublished ? 'unpublish' : 'publish'
|
||||
await api.post(`/courses/${courseId}/${action}`)
|
||||
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, status: currentlyPublished ? 'draft' : 'published' } : c))
|
||||
} catch { }
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +115,7 @@ export default function CoursesPage() {
|
|||
<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 === '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>
|
||||
|
|
@ -134,7 +136,11 @@ export default function CoursesPage() {
|
|||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{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>
|
||||
{course.description && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 0, lineHeight: 1.5 }}>
|
||||
|
|
@ -146,11 +152,17 @@ export default function CoursesPage() {
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
{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>
|
||||
)}
|
||||
</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'}
|
||||
</div>
|
||||
<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 style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{course.progress || 0}% complete
|
||||
{course.progress_pct || 0}% complete
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<span style={{
|
||||
fontSize: '0.72rem', padding: '2px 8px', borderRadius: 10, fontWeight: 600,
|
||||
background: course.is_published ? '#d1fae5' : '#fef3c7',
|
||||
color: course.is_published ? '#065f46' : '#92400e',
|
||||
background: course.status === 'published' ? '#d1fae5' : '#fef3c7',
|
||||
color: course.status === 'published' ? '#065f46' : '#92400e',
|
||||
}}>
|
||||
{course.is_published ? 'Published' : 'Draft'}
|
||||
{course.status === 'published' ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
{course.description && (
|
||||
|
|
@ -263,8 +275,8 @@ export default function CoursesPage() {
|
|||
</div>
|
||||
<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={() => togglePublish(course.id, course.is_published)}>
|
||||
{course.is_published ? 'Unpublish' : 'Publish'}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.status === 'published')}>
|
||||
{course.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
{deletingId === course.id ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ function greeting(name) {
|
|||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [history, setHistory] = useState([])
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||
|
|
@ -39,12 +38,10 @@ export default function DashboardPage() {
|
|||
|
||||
useEffect(() => {
|
||||
const promises = [
|
||||
isModerator ? api.get('/documents/') : Promise.resolve({ data: [] }),
|
||||
api.get('/attempts/stats/dashboard'),
|
||||
api.get('/attempts/history'),
|
||||
]
|
||||
Promise.all(promises).then(([docsRes, statsRes, histRes]) => {
|
||||
setDocuments(docsRes.data)
|
||||
Promise.all(promises).then(([statsRes, histRes]) => {
|
||||
setStats(statsRes.data)
|
||||
setHistory(histRes.data)
|
||||
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||
|
|
@ -66,7 +63,6 @@ export default function DashboardPage() {
|
|||
{stats && (
|
||||
<div className="stats-grid">
|
||||
{[
|
||||
...(isModerator ? [{ value: stats.total_documents, label: 'Documents' }] : []),
|
||||
{ value: stats.total_quizzes, label: 'Quizzes' },
|
||||
{ value: stats.total_attempts, label: 'Attempts' },
|
||||
{ value: `${stats.average_score}%`, label: 'Avg Score' },
|
||||
|
|
@ -159,28 +155,8 @@ export default function DashboardPage() {
|
|||
)}
|
||||
|
||||
{isModerator && (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<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 style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export default function ForgotPasswordPage() {
|
|||
if (err.response?.status === 429) {
|
||||
setError('Too many reset requests. Please wait before trying again.')
|
||||
} 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 {
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
const [resendSent, setResendSent] = useState(false)
|
||||
const [resending, setResending] = useState(false)
|
||||
const [registered, setRegistered] = useState(false)
|
||||
const [passwordWarning, setPasswordWarning] = useState('')
|
||||
|
||||
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
|
||||
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
|
||||
|
|
@ -202,6 +203,9 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
setLoading(true)
|
||||
try {
|
||||
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) {
|
||||
setRegistered(true)
|
||||
} else {
|
||||
|
|
@ -210,7 +214,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
navigate('/')
|
||||
}
|
||||
} 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) }
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +266,11 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
<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.
|
||||
</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') }}>
|
||||
Go to Sign In
|
||||
</button>
|
||||
|
|
@ -456,7 +468,7 @@ export default function LandingPage() {
|
|||
))}
|
||||
</div>
|
||||
<a
|
||||
href="https://peds.danvics.com"
|
||||
href="https://scribe.pedshub.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
|
|
@ -521,7 +533,7 @@ export default function LandingPage() {
|
|||
<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>
|
||||
<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>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ export default function LoginPage() {
|
|||
if (err.response?.status === 403) {
|
||||
setUnverified(true)
|
||||
} 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 {
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -6,21 +6,37 @@ import Dialog from '../components/Dialog'
|
|||
import { useDialog } from '../hooks/useDialog'
|
||||
|
||||
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)
|
||||
return (
|
||||
<>
|
||||
<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()}>
|
||||
<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' }}>
|
||||
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
|
||||
</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>
|
||||
<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 && (
|
||||
<div style={{ margin: '0 0 14px' }}>
|
||||
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
|
||||
|
|
@ -210,15 +226,8 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
|||
setSaving(true); setError('')
|
||||
try {
|
||||
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(`/quizzes/${question.quiz_id}/questions/${question.id}`, payload)
|
||||
// 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,
|
||||
const res = await api.patch(`/questions/${question.id}`, payload)
|
||||
onSaved({ ...question, ...res.data,
|
||||
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
|
||||
onClose()
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
|
||||
|
|
@ -283,27 +292,72 @@ function QuestionEditModal({ question, categories, onSaved, 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 [error, setError] = useState('')
|
||||
const [imageTab, setImageTab] = useState('upload') // 'upload' | 'bank'
|
||||
const [imageBank, setImageBank] = useState([])
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
|
||||
const setOption = (i, val) => {
|
||||
const updated = [...form.options]
|
||||
const wasCorrect = form.options[i] === form.correct_answer
|
||||
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 () => {
|
||||
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('')
|
||||
try {
|
||||
const payload = {
|
||||
question_text: form.question_text,
|
||||
question_type: form.question_type,
|
||||
options: form.options.filter(o => o.trim()),
|
||||
options: validOpts,
|
||||
correct_answer: form.correct_answer,
|
||||
explanation: form.explanation || 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)
|
||||
onCreated(res.data)
|
||||
|
|
@ -314,42 +368,127 @@ function CreateQuestionModal({ categories, onCreated, onClose }) {
|
|||
|
||||
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={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 560, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||||
<h2 style={{ marginBottom: 16 }}>Create Question</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<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 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)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: '1.15rem', margin: 0 }}>Create Question</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
|
||||
|
||||
<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 }))}
|
||||
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>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="form-group">
|
||||
<label>Options</label>
|
||||
{form.options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', width: 20 }}>{String.fromCharCode(65 + i)}</span>
|
||||
<input value={opt} onChange={e => setOption(i, e.target.value)} placeholder={`Option ${String.fromCharCode(65 + i)}`}
|
||||
style={{ flex: 1, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem' }} />
|
||||
<input type="radio" name="correct" checked={form.correct_answer === opt && opt !== ''} onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
|
||||
title="Mark as correct" />
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Image (optional)</label>
|
||||
{form.image_path ? (
|
||||
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 8 }}>
|
||||
<img src={`/uploads/${form.image_path}`} alt="Question"
|
||||
style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 8, border: '1px solid var(--border)' }} />
|
||||
<button onClick={() => setForm(f => ({ ...f, image_path: '' }))}
|
||||
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>
|
||||
</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 className="form-group">
|
||||
<label>Explanation (optional)</label>
|
||||
<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
|
||||
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 style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>
|
||||
Options — click the letter to mark as correct
|
||||
</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 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'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
|
|
@ -370,6 +509,7 @@ export default function QuestionBankPage() {
|
|||
const [filterCatIds, setFilterCatIds] = useState([])
|
||||
const [showUncategorized, setShowUncategorized] = useState(false)
|
||||
const [showFavorites, setShowFavorites] = useState(false)
|
||||
const [showMyQuestions, setShowMyQuestions] = useState(false)
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [studyQuestion, setStudyQuestion] = useState(null)
|
||||
|
|
@ -388,6 +528,10 @@ export default function QuestionBankPage() {
|
|||
const [classifyStatus, setClassifyStatus] = useState(null)
|
||||
const [classifySteps, setClassifySteps] = useState([])
|
||||
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 classifyPollRef = useRef(null)
|
||||
const { user } = useAuth()
|
||||
|
|
@ -412,6 +556,7 @@ export default function QuestionBankPage() {
|
|||
if (catIds.length > 0) params.category_ids = catIds.join(',')
|
||||
if (uncatOnly) params.uncategorized = true
|
||||
if (favOnly) params.favorites_only = true
|
||||
if (showMyQuestions) params.my_questions = true
|
||||
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
|
||||
const res = await api.get('/questions/bank', { params })
|
||||
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
|
||||
|
|
@ -426,7 +571,7 @@ export default function QuestionBankPage() {
|
|||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, searchMode, pageSize, tagIdsKey])
|
||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTagIds(prev => {
|
||||
|
|
@ -535,6 +680,26 @@ export default function QuestionBankPage() {
|
|||
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 isFavorited = favorites.includes(questionId)
|
||||
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}
|
||||
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
|
||||
onClose={() => setEditQuestion(null)} />}
|
||||
{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)} />}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
||||
|
|
@ -600,6 +807,7 @@ export default function QuestionBankPage() {
|
|||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -622,8 +830,10 @@ export default function QuestionBankPage() {
|
|||
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
|
||||
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
||||
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'}`}
|
||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false) }}>Uncategorized</button>
|
||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false); setShowMyQuestions(false) }}>Uncategorized</button>
|
||||
{categories.map(cat => {
|
||||
const isActive = filterCatIds.includes(cat.id)
|
||||
return (
|
||||
|
|
@ -772,7 +982,7 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
</div>
|
||||
<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>
|
||||
{q.options && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
|
|
@ -804,7 +1014,26 @@ export default function QuestionBankPage() {
|
|||
{favorites.includes(q.id) ? '⭐' : '☆'}
|
||||
</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" 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 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 }) {
|
||||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||||
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() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const returnTo = searchParams.get('return_to')
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [quiz, setQuiz] = useState(null)
|
||||
|
|
@ -170,10 +195,11 @@ export default function QuizPage() {
|
|||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
const savedAnswers = saved.answers || {}
|
||||
|
||||
const aid = saved.attempt_id || saved.attemptId || ''
|
||||
// For study mode, load quiz with correct answers BEFORE showing
|
||||
if (mode === 'study') {
|
||||
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)
|
||||
} catch (err) {
|
||||
console.error('Failed to load quiz:', err)
|
||||
|
|
@ -183,7 +209,7 @@ export default function QuizPage() {
|
|||
} else {
|
||||
// For exam mode, ensure quiz data is loaded
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}`)
|
||||
const quizRes = await api.get(`/quizzes/${id}${aid ? `?attempt_id=${aid}` : ''}`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch (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)
|
||||
|
||||
try {
|
||||
let quizData = quiz
|
||||
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)
|
||||
// Start attempt first (may select random question subset)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${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 now = new Date().toISOString()
|
||||
setStartedAt(now)
|
||||
|
|
@ -341,7 +368,11 @@ const timerStarted = timeLeft !== null
|
|||
})),
|
||||
}
|
||||
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) {
|
||||
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
|
||||
} finally { setSubmitting(false) }
|
||||
|
|
@ -362,6 +393,8 @@ const timerStarted = timeLeft !== null
|
|||
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz…</div>
|
||||
</div>
|
||||
) : returnTo ? (
|
||||
<CourseQuizStart quiz={quiz} onStart={startQuiz} />
|
||||
) : (
|
||||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
||||
)}
|
||||
|
|
@ -415,11 +448,13 @@ const timerStarted = timeLeft !== null
|
|||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>⏸</div>
|
||||
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
|
||||
<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 && (
|
||||
<><br/><br/>
|
||||
<strong style={{ color: '#f59e0b' }}>⚠️ Timer continues:</strong> This quiz is timed.
|
||||
If you leave, the timer keeps counting and will auto-submit when it expires.
|
||||
<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.
|
||||
{returnTo && quiz?.max_attempts && (
|
||||
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -460,7 +495,7 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{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
|
||||
</button>
|
||||
{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 [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [passwordWarning, setPasswordWarning] = useState('')
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
const { loginWithToken } = useAuth()
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ export default function RegisterPage() {
|
|||
setLoading(true)
|
||||
try {
|
||||
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) {
|
||||
setDone(true)
|
||||
} else {
|
||||
|
|
@ -53,7 +57,12 @@ export default function RegisterPage() {
|
|||
window.location.href = '/'
|
||||
}
|
||||
} 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 {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -71,6 +80,11 @@ export default function RegisterPage() {
|
|||
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
||||
Click the link in the email to activate your account, then you can log in.
|
||||
</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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ export default function ResetPasswordPage() {
|
|||
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
||||
setLoading(true)
|
||||
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)
|
||||
setTimeout(() => navigate('/login'), 2500)
|
||||
setTimeout(() => navigate('/login'), 3500)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
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'
|
||||
|
||||
export default function ResultsPage() {
|
||||
const { id } = useParams()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const returnTo = searchParams.get('return_to')
|
||||
const [result, setResult] = useState(location.state?.result || null)
|
||||
const [loading, setLoading] = useState(!result)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
const isCourseQuiz = result?.course_id != null
|
||||
const reviewAllowed = result?.allow_review !== false
|
||||
|
||||
const deleteAttempt = async () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return }
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/attempts/${result.id}`)
|
||||
navigate('/quizzes')
|
||||
navigate(returnTo || '/quizzes')
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
setConfirmDelete(false)
|
||||
|
|
@ -27,7 +32,7 @@ export default function ResultsPage() {
|
|||
if (!result) {
|
||||
api.get(`/attempts/${id}`)
|
||||
.then(res => setResult(res.data))
|
||||
.catch(() => navigate('/quizzes'))
|
||||
.catch(() => navigate(returnTo || '/quizzes'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [id])
|
||||
|
|
@ -37,7 +42,7 @@ export default function ResultsPage() {
|
|||
|
||||
const pct = result.percentage
|
||||
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
|
||||
|
||||
return (
|
||||
|
|
@ -71,131 +76,148 @@ export default function ResultsPage() {
|
|||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||
{confirmDelete ? (
|
||||
{isCourseQuiz ? (
|
||||
<>
|
||||
<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>
|
||||
{returnTo && (
|
||||
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={deleteAttempt}
|
||||
className="btn btn-secondary"
|
||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||
>
|
||||
Delete Attempt
|
||||
</button>
|
||||
<>
|
||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||
{confirmDelete ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Summary row */}
|
||||
{result.answers && result.answers.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
||||
{ 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)' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
{/* Question review — only if allowed */}
|
||||
{reviewAllowed && result.answers && result.answers.length > 0 && (
|
||||
<>
|
||||
{/* Summary row */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
||||
{ 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)' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Options with letter badges */}
|
||||
{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>
|
||||
)}
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
||||
|
||||
{/* 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>}
|
||||
{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>
|
||||
<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>
|
||||
{!ans.is_correct && (
|
||||
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
|
||||
<strong>Correct answer:</strong> {ans.correct_answer}
|
||||
|
||||
{/* Options with letter badges */}
|
||||
{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>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Explanation */}
|
||||
{ans.explanation && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation</strong>
|
||||
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!reviewAllowed && (
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
|
||||
</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() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
|
@ -240,6 +277,7 @@ export default function SettingsPage() {
|
|||
<ProfileSection user={user} />
|
||||
<AppearanceSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{isModerator && <DocumentsSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -236,7 +236,6 @@ export default function UploadPage() {
|
|||
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
|
||||
Upload & Process
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue